Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
1
plugins/__init__.py
Normal file
1
plugins/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""ShopDB plugins package."""
|
||||
5
plugins/computers/__init__.py
Normal file
5
plugins/computers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Computers plugin for ShopDB."""
|
||||
|
||||
from .plugin import ComputersPlugin
|
||||
|
||||
__all__ = ['ComputersPlugin']
|
||||
5
plugins/computers/api/__init__.py
Normal file
5
plugins/computers/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Computers plugin API."""
|
||||
|
||||
from .routes import computers_bp
|
||||
|
||||
__all__ = ['computers_bp']
|
||||
894
plugins/computers/api/routes.py
Normal file
894
plugins/computers/api/routes.py
Normal file
@@ -0,0 +1,894 @@
|
||||
"""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('', 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
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Asset.assetnumber.ilike(f'%{search}%'),
|
||||
Asset.name.ilike(f'%{search}%'),
|
||||
Asset.serialnumber.ilike(f'%{search}%'),
|
||||
Computer.hostname.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
})
|
||||
23
plugins/computers/manifest.json
Normal file
23
plugins/computers/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "computers",
|
||||
"version": "1.0.0",
|
||||
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/computers",
|
||||
"provides": {
|
||||
"asset_type": "computer",
|
||||
"features": [
|
||||
"computer_tracking",
|
||||
"software_inventory",
|
||||
"remote_access",
|
||||
"os_management"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"enable_winrm": true,
|
||||
"enable_vnc": true,
|
||||
"auto_report_interval_hours": 24
|
||||
}
|
||||
}
|
||||
14
plugins/computers/migrations/env.py
Normal file
14
plugins/computers/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the computers plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which
|
||||
filters the metadata to this plugin's tables and drives Alembic against the
|
||||
per-plugin version table alembic_version_computers. See ADR-008 for the
|
||||
ownership cutover between the core chain and per-plugin chains.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'computers'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/computers/migrations/script.py.mako
Normal file
24
plugins/computers/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""computers plugin anchor (ownership cutover).
|
||||
|
||||
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
|
||||
already created every table this plugin owns at the cutover point, so there is
|
||||
nothing to build here. This revision gives the plugin's own chain a base that
|
||||
`flask plugin upgrade-all` can stamp into alembic_version_computers. From this
|
||||
anchor forward, new computers schema changes land as 000N revisions in this
|
||||
directory, never in the core chain. See ADR-008.
|
||||
"""
|
||||
from alembic import op # noqa: F401
|
||||
import sqlalchemy as sa # noqa: F401
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'computers0001anchor'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No-op: the core chain owns every table that exists at the cutover.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No-op: this anchor never unwinds core-owned tables.
|
||||
pass
|
||||
17
plugins/computers/models/__init__.py
Normal file
17
plugins/computers/models/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Computers plugin models."""
|
||||
|
||||
from .computer import (
|
||||
Computer,
|
||||
ComputerType,
|
||||
ComputerInstalledApp,
|
||||
AccessProtocol,
|
||||
ComputerAccess,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Computer',
|
||||
'ComputerType',
|
||||
'ComputerInstalledApp',
|
||||
'AccessProtocol',
|
||||
'ComputerAccess',
|
||||
]
|
||||
284
plugins/computers/models/computer.py
Normal file
284
plugins/computers/models/computer.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""Computer plugin models."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
# naive UTC to match the other DB DateTime columns (stored without tzinfo)
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class ComputerType(BaseModel):
|
||||
"""
|
||||
Computer type classification.
|
||||
|
||||
Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc.
|
||||
"""
|
||||
__tablename__ = 'computertypes'
|
||||
|
||||
computertypeid = db.Column(db.Integer, primary_key=True)
|
||||
computertype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ComputerType {self.computertype}>"
|
||||
|
||||
|
||||
class Computer(BaseModel):
|
||||
"""
|
||||
Computer-specific extension data.
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores computer-specific fields like hostname, OS, logged in user, etc.
|
||||
"""
|
||||
__tablename__ = 'computers'
|
||||
|
||||
computerid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Computer classification
|
||||
computertypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('computertypes.computertypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Network identity
|
||||
hostname = db.Column(
|
||||
db.String(100),
|
||||
comment='Network hostname'
|
||||
)
|
||||
|
||||
# Operating system
|
||||
osid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('operatingsystems.osid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Hardware make/model (PCs carry vendor + model like machines)
|
||||
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)
|
||||
lastboottime = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Remote access is now modeled per-protocol via the accessmethods
|
||||
# relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm.
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('computer', uselist=False, lazy='joined')
|
||||
)
|
||||
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(
|
||||
'ComputerInstalledApp',
|
||||
back_populates='computer',
|
||||
cascade='all, delete-orphan',
|
||||
lazy='dynamic'
|
||||
)
|
||||
|
||||
# Remote-access protocols enabled on this PC (replaces isvnc/iswinrm)
|
||||
accessmethods = db.relationship(
|
||||
'ComputerAccess',
|
||||
back_populates='computer',
|
||||
cascade='all, delete-orphan',
|
||||
lazy='selectin'
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_computer_type', 'computertypeid'),
|
||||
db.Index('idx_computer_hostname', 'hostname'),
|
||||
db.Index('idx_computer_os', 'osid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Computer {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.computertype:
|
||||
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
|
||||
if self.model.imageurl:
|
||||
result['imageurl'] = self.model.imageurl
|
||||
|
||||
# Names of enabled remote-access protocols (for list badges)
|
||||
result['accessprotocolnames'] = [
|
||||
am.protocol.name for am in self.accessmethods
|
||||
if am.isactive and am.protocol and am.protocol.isactive
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ComputerInstalledApp(db.Model):
|
||||
"""
|
||||
Junction table for applications installed on computers.
|
||||
|
||||
Tracks which applications are installed on which computers,
|
||||
including version information.
|
||||
"""
|
||||
__tablename__ = 'computerinstalledapps'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
computerid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('computers.computerid', ondelete='CASCADE'),
|
||||
nullable=False
|
||||
)
|
||||
appid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('applications.appid'),
|
||||
nullable=False
|
||||
)
|
||||
appversionid = db.Column(
|
||||
db.Integer,
|
||||
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=_utcnow)
|
||||
|
||||
# Relationships
|
||||
computer = db.relationship('Computer', back_populates='installedapps')
|
||||
application = db.relationship('Application')
|
||||
appversion = db.relationship('AppVersion')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'),
|
||||
db.Index('idx_compapp_computer', 'computerid'),
|
||||
db.Index('idx_compapp_app', 'appid'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
# Curated AppVersion wins; else the raw collected version string.
|
||||
version = None
|
||||
if self.appversion is not None:
|
||||
version = self.appversion.version
|
||||
elif self.installedversion:
|
||||
version = self.installedversion
|
||||
return {
|
||||
'id': self.id,
|
||||
'computerid': self.computerid,
|
||||
'appid': self.appid,
|
||||
'appname': self.application.appname if self.application else None,
|
||||
'appdescription': self.application.appdescription if self.application else None,
|
||||
'appversionid': self.appversionid,
|
||||
'installedversion': version,
|
||||
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None,
|
||||
'isactive': self.isactive,
|
||||
}
|
||||
|
||||
|
||||
class AccessProtocol(db.Model):
|
||||
"""
|
||||
Catalog of remote-access protocols a PC can expose (VNC, WinRM, RDP, SSH...).
|
||||
|
||||
linktemplate builds a connection URL from placeholders {host}, {port},
|
||||
{scheme}. {host} is the PC hostname joined to the pc_access_domain setting.
|
||||
Admin-managed; replaces the old fixed isvnc/iswinrm booleans.
|
||||
"""
|
||||
__tablename__ = 'accessprotocols'
|
||||
|
||||
protocolid = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(50), unique=True, nullable=False)
|
||||
scheme = db.Column(db.String(20), nullable=False)
|
||||
defaultport = db.Column(db.Integer, nullable=True)
|
||||
linktemplate = db.Column(db.String(255), nullable=False)
|
||||
isactive = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'protocolid': self.protocolid,
|
||||
'name': self.name,
|
||||
'scheme': self.scheme,
|
||||
'defaultport': self.defaultport,
|
||||
'linktemplate': self.linktemplate,
|
||||
'isactive': bool(self.isactive),
|
||||
}
|
||||
|
||||
|
||||
class ComputerAccess(db.Model):
|
||||
"""A protocol enabled on a specific PC, with an optional port override."""
|
||||
__tablename__ = 'computeraccess'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
computerid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('computers.computerid', ondelete='CASCADE'),
|
||||
nullable=False
|
||||
)
|
||||
protocolid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('accessprotocols.protocolid'),
|
||||
nullable=False
|
||||
)
|
||||
portoverride = db.Column(db.Integer, nullable=True)
|
||||
isactive = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
protocol = db.relationship('AccessProtocol')
|
||||
computer = db.relationship('Computer', back_populates='accessmethods')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('computerid', 'protocolid', name='uq_computer_protocol'),
|
||||
db.Index('idx_compaccess_computer', 'computerid'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'computerid': self.computerid,
|
||||
'appid': self.appid,
|
||||
'appversionid': self.appversionid,
|
||||
'isactive': self.isactive,
|
||||
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None,
|
||||
'application': {
|
||||
'appid': self.application.appid,
|
||||
'appname': self.application.appname,
|
||||
'appdescription': self.application.appdescription,
|
||||
} if self.application else None,
|
||||
'version': self.appversion.version if self.appversion else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ComputerInstalledApp computer={self.computerid} app={self.appid}>"
|
||||
68
plugins/computers/pctypemap.py
Normal file
68
plugins/computers/pctypemap.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""PXE/GE-Enforce pc-type -> ComputerType mapping (computers-plugin domain).
|
||||
|
||||
The imaging pipeline writes C:\\Enrollment\\pc-type.txt (the gea-shopfloor-*
|
||||
taxonomy). The collector maps that to a flask ComputerType. Defaults live here;
|
||||
per-site overrides live in core Settings (pctypemap_<pxetype>, category
|
||||
'pctypemapping') and are editable in the Settings UI. This indirection means
|
||||
the imaging taxonomy can change without code edits.
|
||||
"""
|
||||
|
||||
from shopdb.api import Setting
|
||||
|
||||
# pxe pc-type -> flask ComputerType name. Defaults; overridable via settings.
|
||||
DEFAULT_PCTYPE_MAP = {
|
||||
'gea-shopfloor-collections': 'Shopfloor PC',
|
||||
'gea-shopfloor-nocollections': 'Shopfloor PC',
|
||||
'gea-shopfloor-common': 'Shopfloor PC',
|
||||
'gea-shopfloor-keyence': 'Shopfloor PC',
|
||||
'gea-shopfloor-cmm': 'CMM PC',
|
||||
'gea-shopfloor-genspect': 'Shopfloor PC',
|
||||
'gea-shopfloor-heattreat': 'Shopfloor PC',
|
||||
'gea-shopfloor-waxtrace': 'Shopfloor PC',
|
||||
'gea-shopfloor-display': 'Kiosk',
|
||||
'gea-shopfloor-partmarker': 'Shopfloor PC',
|
||||
}
|
||||
|
||||
# DEPRECATED settings surface: the "Collector PC Types" settings page is retired
|
||||
# (GE-Enforce scope computertypeid supersedes it, ADR-012). Collector still reads
|
||||
# this map, so keep the code; only the editor UI card is gone.
|
||||
_SETTING_PREFIX = 'pctypemap_'
|
||||
_SETTING_CATEGORY = 'pctypemapping'
|
||||
|
||||
# Metrology imaging pc-types: the PC itself stays a shopfloor PC, but it drives
|
||||
# an attached measuring instrument. When the collector sees one of these, it
|
||||
# auto-creates a MeasuringTool asset for the device and links the PC to it with
|
||||
# a "controls" relationship (see ComputersPlugin._sync_measuringtool_link).
|
||||
# Maps imaging pc-type -> (MeasuringToolType name, type description). The type
|
||||
# is created on demand if the measuringtools plugin has not seeded it.
|
||||
METROLOGY_TOOL_MAP = {
|
||||
'gea-shopfloor-cmm': ('CMM', 'Coordinate measuring machine'),
|
||||
'gea-shopfloor-keyence': ('Vision System', 'Optical / vision measurement system (Keyence)'),
|
||||
'gea-shopfloor-genspect': ('Genspect', 'Genspect visual / borescope inspection system'),
|
||||
'gea-shopfloor-waxtrace': ('Form Tracer', 'Surface / form tracing system (wax and trace)'),
|
||||
}
|
||||
|
||||
|
||||
def metrology_tool_for(pctype):
|
||||
"""Return (typename, typedescription) if this pc-type drives a measuring
|
||||
tool, else None."""
|
||||
return METROLOGY_TOOL_MAP.get((pctype or '').strip())
|
||||
|
||||
|
||||
def pctype_mapping():
|
||||
"""Return the live pctype -> ComputerType-name map (settings over defaults)."""
|
||||
mapping = dict(DEFAULT_PCTYPE_MAP)
|
||||
for setting in Setting.query.filter_by(category=_SETTING_CATEGORY).all():
|
||||
if setting.key.startswith(_SETTING_PREFIX):
|
||||
mapping[setting.key[len(_SETTING_PREFIX):]] = setting.get_typed_value()
|
||||
return mapping
|
||||
|
||||
|
||||
def seed_pctype_settings():
|
||||
"""Seed the pctypemap_<pxetype> settings from defaults (idempotent)."""
|
||||
for pxetype, computertype in DEFAULT_PCTYPE_MAP.items():
|
||||
key = f'{_SETTING_PREFIX}{pxetype}'
|
||||
if not Setting.query.filter_by(key=key).first():
|
||||
Setting.set(key, computertype, valuetype='string',
|
||||
category=_SETTING_CATEGORY,
|
||||
description=f'Collector maps pc-type {pxetype} to this ComputerType')
|
||||
680
plugins/computers/plugin.py
Normal file
680
plugins/computers/plugin.py
Normal file
@@ -0,0 +1,680 @@
|
||||
"""Computers plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||
from .api import computers_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Marker stamped on PC->printer links this collector creates. The stale-link
|
||||
# archive only touches rows carrying this label, so manually-created printer
|
||||
# relationships are never removed by a collector push. Stored in the
|
||||
# assetrelationships.label column (no origin column exists; see BUILD notes).
|
||||
PRINTER_LINK_ORIGIN = 'collector:printers'
|
||||
|
||||
# Marker stamped on PC->measuringtool "controls" links this collector creates
|
||||
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Same stale-link
|
||||
# discipline as PRINTER_LINK_ORIGIN: only rows carrying this label are archived
|
||||
# by a collector push, so hand-made tool links survive.
|
||||
MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
||||
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
"""
|
||||
Computers plugin - manages PC, server, and workstation assets.
|
||||
|
||||
Computers include shopfloor PCs, engineer workstations, servers, etc.
|
||||
Uses the new Asset architecture with Computer extension table.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
if manifestpath.exists():
|
||||
with open(manifestpath, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'computers'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Computer management for PCs, servers, and workstations'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/computers'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return computers_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Computers plugin initialized (v{self.meta.version})")
|
||||
|
||||
# -- ADR-006 collector contract -----------------------------------------
|
||||
|
||||
def get_collector_schema(self) -> Optional[Dict]:
|
||||
"""Schema for the PC collector payload (matched by hostname).
|
||||
|
||||
Aligns with the GE-Enforce status shape (transport may change), using
|
||||
the project naming convention (lowercase concatenated). The caller maps
|
||||
its own field names to these.
|
||||
"""
|
||||
return {
|
||||
'identityfield': 'hostname',
|
||||
'fields': {
|
||||
'hostname': {'type': 'string', 'required': True},
|
||||
'machinenumber': {'type': 'string'},
|
||||
'pctype': {'type': 'string'},
|
||||
'pcsubtype': {'type': 'string'},
|
||||
'serialnumber': {'type': 'string'},
|
||||
'loggedinuser': {'type': 'string'},
|
||||
'lastboottime': {'type': 'string', 'format': 'date-time'},
|
||||
'lastcheckin': {'type': 'string', 'format': 'date-time'},
|
||||
'ipaddress': {'type': 'string'},
|
||||
'vendorname': {'type': 'string'},
|
||||
'modelnumber': {'type': 'string'},
|
||||
'osname': {'type': 'string'},
|
||||
'installedsoftware': {
|
||||
'type': 'array',
|
||||
'items': {'name': 'string', 'version': 'string'},
|
||||
},
|
||||
# Printer identifiers reported by the GE-Enforce side, which
|
||||
# runs Get-CimInstance Win32_Printer and marks the default with
|
||||
# the Default flag. An identifier is the printer's windows name,
|
||||
# share name, hostname, or port/IP; the collector resolves it
|
||||
# flexibly to a printer asset. Both optional. Presence of either
|
||||
# key drives the PC->printer relationship sync (and stale-link
|
||||
# archive); absence leaves existing printer links untouched.
|
||||
'defaultprinter': {
|
||||
'type': 'string',
|
||||
'description': ('Default printer identifier (Win32_Printer '
|
||||
'with Default=true): windows name, share '
|
||||
'name, hostname, or port IP.'),
|
||||
},
|
||||
'printers': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': ('All installed network printer identifiers '
|
||||
'(Win32_Printer): windows name / share / '
|
||||
'hostname / IP. Unresolved -> warning.'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.api import (
|
||||
Asset, Application, Communication, CommunicationType,
|
||||
Vendor, Model, OperatingSystem,
|
||||
)
|
||||
from .pctypemap import pctype_mapping
|
||||
|
||||
warnings = []
|
||||
hostname = (payload.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
raise ValueError('hostname is required')
|
||||
|
||||
# Machine number is the business identifier (Asset.assetnumber). Skip
|
||||
# the imaging-time placeholder '9999' and fall back to hostname.
|
||||
machinenumber = (payload.get('machinenumber') or '').strip()
|
||||
if machinenumber in ('', '9999'):
|
||||
machinenumber = None
|
||||
|
||||
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
|
||||
if not comp:
|
||||
comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid)
|
||||
.filter(Asset.assetnumber.ilike(hostname)).first())
|
||||
|
||||
action = 'updated'
|
||||
if not comp:
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
# statusid=1 is the first seeded asset status ("In Use"); a
|
||||
# collector-discovered PC is by definition in use.
|
||||
asset = Asset(assetnumber=machinenumber or hostname,
|
||||
assettypeid=atype.assettypeid, statusid=1)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname=hostname)
|
||||
db.session.add(comp)
|
||||
db.session.flush()
|
||||
action = 'created'
|
||||
elif machinenumber and comp.asset:
|
||||
comp.asset.assetnumber = machinenumber
|
||||
|
||||
comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
if payload.get('lastboottime'):
|
||||
try:
|
||||
comp.lastboottime = datetime.fromisoformat(
|
||||
payload['lastboottime'].replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
warnings.append('lastboottime not parseable')
|
||||
loggedinuser = payload.get('loggedinuser') or payload.get('currentuser')
|
||||
if loggedinuser:
|
||||
comp.loggedinuser = loggedinuser
|
||||
if payload.get('serialnumber') and comp.asset:
|
||||
comp.asset.serialnumber = payload['serialnumber']
|
||||
|
||||
# pc-type -> ComputerType via the configurable settings mapping.
|
||||
pctype = (payload.get('pctype') or '').strip()
|
||||
if pctype:
|
||||
from .models import ComputerType
|
||||
mapped = pctype_mapping().get(pctype)
|
||||
if not mapped:
|
||||
warnings.append(f'no ComputerType mapping for pctype: {pctype}')
|
||||
else:
|
||||
ctype = ComputerType.query.filter_by(computertype=mapped).first()
|
||||
if ctype:
|
||||
comp.computertypeid = ctype.computertypeid
|
||||
else:
|
||||
warnings.append(f'mapped ComputerType not found: {mapped}')
|
||||
|
||||
# Vendor / model are free vocab - create if missing.
|
||||
vendorname = (payload.get('vendorname') or '').strip()
|
||||
vendor = None
|
||||
if vendorname:
|
||||
vendor = Vendor.query.filter(Vendor.vendor.ilike(vendorname)).first()
|
||||
if not vendor:
|
||||
vendor = Vendor(vendor=vendorname)
|
||||
db.session.add(vendor)
|
||||
db.session.flush()
|
||||
comp.vendorid = vendor.vendorid
|
||||
|
||||
modelnumber = (payload.get('modelnumber') or '').strip()
|
||||
if modelnumber:
|
||||
model_query = Model.query.filter(Model.modelnumber.ilike(modelnumber))
|
||||
if vendor:
|
||||
model_query = model_query.filter(Model.vendorid == vendor.vendorid)
|
||||
model = model_query.first()
|
||||
if not model:
|
||||
model = Model(modelnumber=modelnumber,
|
||||
vendorid=vendor.vendorid if vendor else None)
|
||||
db.session.add(model)
|
||||
db.session.flush()
|
||||
comp.modelnumberid = model.modelnumberid
|
||||
|
||||
# OS is a controlled vocab - look up only, warn if unknown.
|
||||
osname = (payload.get('osname') or '').strip()
|
||||
if osname:
|
||||
os_row = OperatingSystem.query.filter(
|
||||
OperatingSystem.osname.ilike(osname)).first()
|
||||
if os_row:
|
||||
comp.osid = os_row.osid
|
||||
else:
|
||||
warnings.append(f'unknown operating system: {osname}')
|
||||
|
||||
if payload.get('pcsubtype'):
|
||||
warnings.append('pcsubtype received but not stored (no model field)')
|
||||
|
||||
if payload.get('ipaddress'):
|
||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
primary = Communication.query.filter_by(
|
||||
assetid=comp.assetid, isprimary=True).first()
|
||||
if primary:
|
||||
primary.ipaddress = payload['ipaddress']
|
||||
elif ip_comtype:
|
||||
db.session.add(Communication(
|
||||
assetid=comp.assetid, comtypeid=ip_comtype.comtypeid,
|
||||
ipaddress=payload['ipaddress'], isprimary=True))
|
||||
|
||||
for app_data in payload.get('installedsoftware', []) or []:
|
||||
name = app_data.get('name')
|
||||
if not name:
|
||||
continue
|
||||
app = Application.query.filter(Application.appname.ilike(name)).first()
|
||||
if not app:
|
||||
warnings.append(f'unknown application: {name}')
|
||||
continue
|
||||
installed = ComputerInstalledApp.query.filter_by(
|
||||
computerid=comp.computerid, appid=app.appid).first()
|
||||
version = app_data.get('version')
|
||||
if installed:
|
||||
installed.installedversion = version
|
||||
installed.isactive = True
|
||||
else:
|
||||
db.session.add(ComputerInstalledApp(
|
||||
computerid=comp.computerid, appid=app.appid,
|
||||
installedversion=version))
|
||||
|
||||
# Printer relationship sync (only when the payload carried printer data).
|
||||
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
|
||||
|
||||
# Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace)
|
||||
# get an attached MeasuringTool asset auto-created and linked.
|
||||
measuringtoollinks = self._sync_measuringtool_link(
|
||||
comp.asset, pctype, hostname, warnings)
|
||||
|
||||
db.session.commit()
|
||||
return {
|
||||
'action': action,
|
||||
'assetid': comp.assetid,
|
||||
'identityvalue': hostname,
|
||||
'warnings': warnings,
|
||||
'extra': {
|
||||
'printerlinks': printerlinks,
|
||||
'printerlinkcount': len(printerlinks),
|
||||
'measuringtoollinks': measuringtoollinks,
|
||||
'measuringtoollinkcount': len(measuringtoollinks),
|
||||
},
|
||||
}
|
||||
|
||||
# -- printer relationship sync -----------------------------------------
|
||||
|
||||
def _sync_printer_links(self, pcasset, payload, warnings):
|
||||
"""Idempotently sync PC->printer relationships from collector printer data.
|
||||
|
||||
Default printer -> 'defaultprinter' (directional). Other reported
|
||||
printers -> 'connectedto' (symmetric). Resolves each identifier to a
|
||||
printer asset by windows name / share / hostname / asset number / name
|
||||
or a communications IP. Unresolved identifiers add a warning and never
|
||||
fail the push.
|
||||
|
||||
Stale-link archive: on each push, collector-tagged links (label ==
|
||||
PRINTER_LINK_ORIGIN) whose (target, type) pair is not in the reported
|
||||
desired set are set inactive. Only tagged rows are touched, so manual
|
||||
links survive. Runs only when the payload carried a printer key
|
||||
('defaultprinter' or 'printers'); a PC that reports without printer data
|
||||
keeps its existing links. Returns the desired-link list (created + kept).
|
||||
"""
|
||||
from shopdb.api import (
|
||||
AssetRelationship, RelationshipType, Asset, Communication)
|
||||
|
||||
has_default = 'defaultprinter' in payload
|
||||
has_list = 'printers' in payload
|
||||
if not has_default and not has_list:
|
||||
return []
|
||||
|
||||
try:
|
||||
from plugins.printers.models import Printer
|
||||
except ImportError:
|
||||
warnings.append('printers plugin unavailable; printer links skipped')
|
||||
return []
|
||||
|
||||
dp_type = RelationshipType.query.filter_by(
|
||||
relationshiptype='defaultprinter').first()
|
||||
ct_type = RelationshipType.query.filter_by(
|
||||
relationshiptype='connectedto').first()
|
||||
if not dp_type or not ct_type:
|
||||
warnings.append('printer relationship types missing; '
|
||||
'run flask seed reference-data')
|
||||
return []
|
||||
|
||||
def resolve(identifier):
|
||||
# first match wins: printer text identity, then a printer IP.
|
||||
ident = (identifier or '').strip()
|
||||
if not ident:
|
||||
return None
|
||||
printer = (
|
||||
Printer.query.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.filter(Asset.isactive == True)
|
||||
.filter(db.or_(
|
||||
Printer.windowsname.ilike(ident),
|
||||
Printer.hostname.ilike(ident),
|
||||
Printer.sharename.ilike(ident),
|
||||
Asset.assetnumber.ilike(ident),
|
||||
Asset.name.ilike(ident),
|
||||
)).first())
|
||||
if printer:
|
||||
return printer.asset
|
||||
comm = (
|
||||
db.session.query(Communication)
|
||||
.join(Printer, Printer.assetid == Communication.assetid)
|
||||
.filter(Communication.ipaddress == ident)
|
||||
.first())
|
||||
if comm:
|
||||
return db.session.get(Asset, comm.assetid)
|
||||
return None
|
||||
|
||||
pcid = pcasset.assetid
|
||||
desired = set() # (targetassetid, relationshiptypeid) to keep
|
||||
printerlinks = []
|
||||
|
||||
default_id = None
|
||||
default_ident = (payload.get('defaultprinter') or '').strip()
|
||||
if default_ident:
|
||||
target = resolve(default_ident)
|
||||
if target:
|
||||
default_id = target.assetid
|
||||
desired.add((default_id, dp_type.relationshiptypeid))
|
||||
self._sync_one(pcid, default_id, dp_type)
|
||||
printerlinks.append({'assetid': default_id,
|
||||
'relationshiptype': 'defaultprinter'})
|
||||
else:
|
||||
warnings.append(f'unresolved default printer: {default_ident}')
|
||||
|
||||
for ident in payload.get('printers') or []:
|
||||
target = resolve(ident)
|
||||
if not target:
|
||||
warnings.append(f'unresolved printer: {ident}')
|
||||
continue
|
||||
if target.assetid == default_id:
|
||||
continue # already the default link
|
||||
desired.add((target.assetid, ct_type.relationshiptypeid))
|
||||
self._sync_one(pcid, target.assetid, ct_type)
|
||||
printerlinks.append({'assetid': target.assetid,
|
||||
'relationshiptype': 'connectedto'})
|
||||
|
||||
# Archive collector-tagged links no longer reported (manual links, with
|
||||
# a NULL/other label, are never matched here).
|
||||
collector_rows = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcid,
|
||||
AssetRelationship.relationshiptypeid.in_(
|
||||
[dp_type.relationshiptypeid, ct_type.relationshiptypeid]),
|
||||
AssetRelationship.isactive == True,
|
||||
AssetRelationship.label == PRINTER_LINK_ORIGIN,
|
||||
).all()
|
||||
for rel in collector_rows:
|
||||
if (rel.targetassetid, rel.relationshiptypeid) not in desired:
|
||||
rel.isactive = False
|
||||
|
||||
return printerlinks
|
||||
|
||||
def _sync_one(self, pcid, printerid, reltype):
|
||||
"""Reactivate or create one collector PC->printer link (idempotent)."""
|
||||
from shopdb.api import AssetRelationship
|
||||
existing = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pcid, targetassetid=printerid,
|
||||
relationshiptypeid=reltype.relationshiptypeid).first()
|
||||
if existing:
|
||||
# do not re-stamp label: a pre-existing manual row stays manual.
|
||||
if not existing.isactive:
|
||||
existing.isactive = True
|
||||
return existing
|
||||
rel = AssetRelationship(
|
||||
sourceassetid=pcid, targetassetid=printerid,
|
||||
relationshiptypeid=reltype.relationshiptypeid,
|
||||
label=PRINTER_LINK_ORIGIN)
|
||||
db.session.add(rel)
|
||||
return rel
|
||||
|
||||
# -- measuring-tool sync -----------------------------------------------
|
||||
|
||||
def _sync_measuringtool_link(self, pcasset, pctype, hostname, warnings):
|
||||
"""Auto-create + link the MeasuringTool a metrology PC drives.
|
||||
|
||||
A CMM / Keyence / Genspect / wax-and-trace imaging pc-type means the
|
||||
shopfloor PC controls an attached measuring instrument. This creates
|
||||
that instrument once as a MeasuringTool asset and a directional
|
||||
PC->tool 'controls' relationship, tagged MEASURINGTOOL_LINK_ORIGIN so
|
||||
it is idempotent and self-archiving. The PC's own ComputerType is left
|
||||
alone (it stays a shopfloor PC). A non-metrology pc-type archives any
|
||||
collector-created tool link (e.g. a PC re-imaged to another type) but
|
||||
never deletes the tool asset, which may carry calibration history.
|
||||
Returns the desired-link list.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
from .pctypemap import metrology_tool_for
|
||||
|
||||
tool_spec = metrology_tool_for(pctype)
|
||||
controls = RelationshipType.query.filter_by(
|
||||
relationshiptype='controls').first()
|
||||
if not controls:
|
||||
# No 'controls' type => no tool links can exist. Only a problem for a
|
||||
# metrology PC that needs one; stay quiet for ordinary PCs.
|
||||
if tool_spec:
|
||||
warnings.append("'controls' relationship type missing; "
|
||||
'run flask seed reference-data')
|
||||
return []
|
||||
|
||||
# Collector-created tool links already on this PC (active or archived).
|
||||
existing = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == MEASURINGTOOL_LINK_ORIGIN,
|
||||
).all()
|
||||
|
||||
if not tool_spec:
|
||||
# Not a metrology PC: archive any collector-created tool link.
|
||||
for rel in existing:
|
||||
if rel.isactive:
|
||||
rel.isactive = False
|
||||
return []
|
||||
|
||||
try:
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
except ImportError:
|
||||
warnings.append('measuringtools plugin unavailable; '
|
||||
'tool link skipped')
|
||||
return []
|
||||
|
||||
typename, typedescription = tool_spec
|
||||
tooltype = self._ensure_measuringtool_type(typename, typedescription)
|
||||
|
||||
# Reuse any prior collector link (reactivate + retype) before creating,
|
||||
# so a re-metrology PC never duplicates the tool asset.
|
||||
reuse = next((rel for rel in existing if rel.isactive), None) \
|
||||
or (existing[0] if existing else None)
|
||||
if reuse:
|
||||
reuse.isactive = True
|
||||
toolasset = db.session.get(Asset, reuse.targetassetid)
|
||||
if toolasset and toolasset.measuringtool and tooltype:
|
||||
toolasset.measuringtool.measuringtooltypeid = \
|
||||
tooltype.measuringtooltypeid
|
||||
targetid = reuse.targetassetid
|
||||
else:
|
||||
mt_assettype = AssetType.query.filter_by(
|
||||
assettype='measuring_tool').first()
|
||||
if not mt_assettype:
|
||||
warnings.append('measuring_tool asset type missing; '
|
||||
'tool link skipped')
|
||||
return []
|
||||
suffix = (pctype or '').split('-')[-1].upper()
|
||||
baseasset = pcasset.assetnumber or hostname
|
||||
toolasset = Asset(
|
||||
assetnumber=f'{baseasset}-{suffix}',
|
||||
name=f'{typename} ({hostname})',
|
||||
assettypeid=mt_assettype.assettypeid,
|
||||
statusid=1)
|
||||
db.session.add(toolasset)
|
||||
db.session.flush()
|
||||
db.session.add(MeasuringTool(
|
||||
assetid=toolasset.assetid,
|
||||
measuringtooltypeid=tooltype.measuringtooltypeid
|
||||
if tooltype else None))
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=toolasset.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MEASURINGTOOL_LINK_ORIGIN))
|
||||
targetid = toolasset.assetid
|
||||
|
||||
# Only one tool link is desired; archive any other collector rows.
|
||||
for rel in existing:
|
||||
if rel is not reuse and rel.isactive:
|
||||
rel.isactive = False
|
||||
|
||||
return [{'assetid': targetid, 'relationshiptype': 'controls',
|
||||
'measuringtooltype': typename}]
|
||||
|
||||
def _ensure_measuringtool_type(self, name, description):
|
||||
"""Find or create a MeasuringToolType (metrology types are not in the
|
||||
measuringtools starter seed)."""
|
||||
from plugins.measuringtools.models import MeasuringToolType
|
||||
tooltype = MeasuringToolType.query.filter_by(name=name).first()
|
||||
if not tooltype:
|
||||
tooltype = MeasuringToolType(name=name, description=description)
|
||||
db.session.add(tooltype)
|
||||
db.session.flush()
|
||||
return tooltype
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_computer_types()
|
||||
from .pctypemap import seed_pctype_settings
|
||||
seed_pctype_settings()
|
||||
db.session.commit()
|
||||
logger.info("Computers plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure computer asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not existing:
|
||||
at = AssetType(
|
||||
assettype='computer',
|
||||
pluginname='computers',
|
||||
tablename='computers',
|
||||
description='PCs, servers, and workstations',
|
||||
icon='desktop'
|
||||
)
|
||||
db.session.add(at)
|
||||
logger.debug("Created asset type: computer")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_computer_types(self) -> None:
|
||||
"""Ensure basic computer types exist."""
|
||||
computer_types = [
|
||||
('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'),
|
||||
('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'),
|
||||
('CMM PC', 'PC dedicated to CMM operation', 'desktop'),
|
||||
('Server', 'Server system', 'server'),
|
||||
('Kiosk', 'Kiosk or info display PC', 'tv'),
|
||||
('Laptop', 'Laptop computer', 'laptop'),
|
||||
('Virtual Machine', 'Virtual machine', 'cloud'),
|
||||
('Other', 'Other computer type', 'desktop'),
|
||||
]
|
||||
|
||||
for name, description, icon in computer_types:
|
||||
existing = ComputerType.query.filter_by(computertype=name).first()
|
||||
if not existing:
|
||||
ct = ComputerType(
|
||||
computertype=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(ct)
|
||||
logger.debug(f"Created computer type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Computers plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('computers')
|
||||
def computerscli():
|
||||
"""Computers plugin commands."""
|
||||
pass
|
||||
|
||||
@computerscli.command('list-types')
|
||||
def list_types():
|
||||
"""List all computer types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = ComputerType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No computer types found.')
|
||||
return
|
||||
|
||||
click.echo('Computer Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.computertypeid}] {t.computertype}")
|
||||
|
||||
@computerscli.command('stats')
|
||||
def stats():
|
||||
"""Show computer statistics."""
|
||||
from flask import current_app
|
||||
from shopdb.api import Asset
|
||||
|
||||
with current_app.app_context():
|
||||
total = db.session.query(Computer).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
).count()
|
||||
|
||||
click.echo(f"Total active computers: {total}")
|
||||
|
||||
# Shopfloor count (by the Shopfloor computer type)
|
||||
sf = ComputerType.query.filter_by(computertype='Shopfloor').first()
|
||||
shopfloor = db.session.query(Computer).join(Asset).filter(
|
||||
Asset.isactive == True,
|
||||
Computer.computertypeid == (sf.computertypeid if sf else -1)
|
||||
).count()
|
||||
|
||||
click.echo(f" Shopfloor PCs: {shopfloor}")
|
||||
click.echo(f" Other: {total - shopfloor}")
|
||||
|
||||
@computerscli.command('find')
|
||||
@click.argument('hostname')
|
||||
def find_by_hostname(hostname):
|
||||
"""Find a computer by hostname."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
comp = Computer.query.filter(
|
||||
Computer.hostname.ilike(f'%{hostname}%')
|
||||
).first()
|
||||
|
||||
if not comp:
|
||||
click.echo(f'No computer found matching hostname: {hostname}')
|
||||
return
|
||||
|
||||
click.echo(f'Found: {comp.hostname}')
|
||||
click.echo(f' Asset: {comp.asset.assetnumber}')
|
||||
click.echo(f' Type: {comp.computertype.computertype if comp.computertype else "N/A"}')
|
||||
click.echo(f' OS: {comp.operatingsystem.osname if comp.operatingsystem else "N/A"}')
|
||||
click.echo(f' Logged in: {comp.loggedinuser or "N/A"}')
|
||||
|
||||
return [computerscli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Computer Status',
|
||||
'component': 'ComputerStatusWidget',
|
||||
'endpoint': '/api/computers/dashboard/summary',
|
||||
'size': 'medium',
|
||||
'position': 6,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'PCs',
|
||||
'icon': 'desktop',
|
||||
'route': '/pcs',
|
||||
'position': 15,
|
||||
},
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""Return the RBAC permissions this plugin owns."""
|
||||
return [
|
||||
('computers.view', 'View computers', 'computers'),
|
||||
('computers.create', 'Create computers', 'computers'),
|
||||
('computers.edit', 'Edit computers', 'computers'),
|
||||
('computers.delete', 'Delete computers', 'computers'),
|
||||
]
|
||||
133
plugins/employees/README.md
Normal file
133
plugins/employees/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Employees plugin - directory database contract
|
||||
|
||||
Read-only lookups against a **separate** employee/HR directory database. Powers:
|
||||
|
||||
- Employee search (add people to a notification, browse the directory)
|
||||
- Single + batch SSO lookup
|
||||
- Recognition notifications (name + photo on the shopfloor / lobby displays)
|
||||
|
||||
The plugin never writes to this database. Use a read-only account.
|
||||
|
||||
> **This is the integration most likely to differ per site.** HR / directory
|
||||
> systems vary widely, so expect to map a site's schema to the contract below -
|
||||
> the `CREATE VIEW` recipe at the end is the normal way to do it.
|
||||
|
||||
## Connection
|
||||
|
||||
Credentials resolve **settings-first, then environment**, except the password
|
||||
which is env-only (never stored in the app database).
|
||||
|
||||
| Field | Setting key (editable in the setup wizard) | Env var (fallback) |
|
||||
| -------- | ------------------------------------------ | ---------------------- |
|
||||
| Host | `employee_db_host` | `EMPLOYEE_DB_HOST` |
|
||||
| Database | `employee_db_name` | `EMPLOYEE_DB_NAME` |
|
||||
| User | `employee_db_user` | `EMPLOYEE_DB_USER` |
|
||||
| Password | (not stored) | `EMPLOYEE_DB_PASSWORD` |
|
||||
|
||||
Set the password in `.env`; the setup wizard's Features step shows the exact
|
||||
line to paste. Engine is MySQL/MariaDB (pymysql).
|
||||
|
||||
## Required schema
|
||||
|
||||
The plugin runs these queries verbatim, so a site's database must expose a
|
||||
table (or **view** - see below) named `employees` with these columns:
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ------------ | ------------------- | ------------------------------------------------- |
|
||||
| `SSO` | INT | Unique person id. Lookups require it to be numeric |
|
||||
| `First_Name` | VARCHAR | Displayed as the given name |
|
||||
| `Last_Name` | VARCHAR | Displayed as the surname; sort key |
|
||||
| `Team` | VARCHAR | Team / group label |
|
||||
| `Role` | VARCHAR | Job title / role |
|
||||
| `Picture` | VARCHAR | **Image filename** (see Photos) |
|
||||
|
||||
Queries actually executed:
|
||||
|
||||
```sql
|
||||
-- search
|
||||
SELECT SSO, First_Name, Last_Name, Team, Role, Picture
|
||||
FROM employees
|
||||
WHERE First_Name LIKE %s OR Last_Name LIKE %s OR CAST(SSO AS CHAR) LIKE %s
|
||||
ORDER BY Last_Name, First_Name LIMIT %s;
|
||||
|
||||
-- single / batch
|
||||
SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO = %s;
|
||||
SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO IN (...);
|
||||
```
|
||||
|
||||
Rows are returned to the API **with these exact column names**. The frontend
|
||||
(EmployeeSearch, NotificationForm, EmployeeDetail, ShopfloorDashboard) reads
|
||||
`SSO`, `First_Name`, `Last_Name`, `Team`, `Role`, `Picture` as-is - do not
|
||||
rename them in the response.
|
||||
|
||||
## Photos
|
||||
|
||||
`Picture` holds an **image filename** (e.g. `123456.jpg`), not a path or blob.
|
||||
The app renders it as `/static/employees/<Picture>`, so the image files must
|
||||
live in the app's `static/employees/` directory. Leave `Picture` empty/NULL for
|
||||
people with no photo; the UI falls back to initials.
|
||||
|
||||
## Two ways to provide the directory
|
||||
|
||||
**Option A - map an existing HR/directory database** (see the view recipe
|
||||
below). Use this when the site already has a system of record for people.
|
||||
|
||||
**Option B - self-hosted directory (managed in-app)** for sites with no HR
|
||||
database. Set `employee_directory_mode` to `selfhosted` and manage people under
|
||||
**Settings > Employee Directory** (add / edit / delete + CSV import) - no SQL
|
||||
needed. The app owns a `directoryemployees` table (migration 7d16); the lookup
|
||||
APIs read it automatically in this mode.
|
||||
|
||||
If you would rather load it with SQL, the canonical table is equivalent to:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE shopdb_directory CHARACTER SET utf8mb4;
|
||||
USE shopdb_directory;
|
||||
|
||||
CREATE TABLE employees (
|
||||
SSO INT NOT NULL PRIMARY KEY,
|
||||
First_Name VARCHAR(100) NOT NULL,
|
||||
Last_Name VARCHAR(100) NOT NULL,
|
||||
Team VARCHAR(100) NULL,
|
||||
Role VARCHAR(100) NULL,
|
||||
Picture VARCHAR(255) NULL
|
||||
);
|
||||
|
||||
-- add people (or bulk-load from CSV with LOAD DATA INFILE)
|
||||
INSERT INTO employees (SSO, First_Name, Last_Name, Team, Role, Picture)
|
||||
VALUES (123456, 'Jane', 'Doe', 'Inspection', 'Quality Tech', '123456.jpg');
|
||||
```
|
||||
|
||||
Put photo files (named as in `Picture`) under the app's `static/employees/`.
|
||||
In self-hosted mode the `directoryemployees` table lives in the main app DB, so
|
||||
no separate database is required.
|
||||
|
||||
## Adapting a different site schema (recommended: a view)
|
||||
|
||||
Sites whose HR/directory database uses different table or column names should
|
||||
**not** be forced to rename anything. Instead, create a read-only **view**
|
||||
named `employees` that maps local columns to the names above:
|
||||
|
||||
```sql
|
||||
CREATE VIEW employees AS
|
||||
SELECT
|
||||
person_id AS SSO,
|
||||
given_name AS First_Name,
|
||||
surname AS Last_Name,
|
||||
department AS Team,
|
||||
job_title AS Role,
|
||||
photo_filename AS Picture
|
||||
FROM hr_people
|
||||
WHERE active = 1;
|
||||
```
|
||||
|
||||
Grant the app's read-only user `SELECT` on the view. No app code changes -
|
||||
point `employee_db_*` at that database and the plugin works.
|
||||
|
||||
Notes:
|
||||
- `SSO` must be numeric (single/batch lookup validate `isdigit()`).
|
||||
- Column-name case follows your database's identifier casing; match the names
|
||||
above exactly on case-sensitive platforms.
|
||||
- If the directory is unreachable or the `employees` object is missing, lookups
|
||||
return a 500 and the rest of the app keeps working (the feature degrades, it
|
||||
does not crash the app).
|
||||
5
plugins/employees/__init__.py
Normal file
5
plugins/employees/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Employees plugin package."""
|
||||
|
||||
from .plugin import EmployeesPlugin
|
||||
|
||||
__all__ = ['EmployeesPlugin']
|
||||
5
plugins/employees/api/__init__.py
Normal file
5
plugins/employees/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Employees plugin API."""
|
||||
|
||||
from .routes import employees_bp
|
||||
|
||||
__all__ = ['employees_bp']
|
||||
504
plugins/employees/api/routes.py
Normal file
504
plugins/employees/api/routes.py
Normal file
@@ -0,0 +1,504 @@
|
||||
"""Employee lookup API endpoints.
|
||||
|
||||
These read from the separate employee directory DB (employee_connection is core
|
||||
infrastructure exposed via shopdb.api, shared with search + notifications). The
|
||||
endpoints are intentionally reachable by the unauthenticated shopfloor kiosk
|
||||
displays (recognition wall), so they are not JWT-gated; keep them read-only and
|
||||
never return more than the directory fields below.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import glob
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, current_app, send_from_directory
|
||||
from flask_jwt_extended import jwt_required
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
success_response,
|
||||
error_response,
|
||||
ErrorCodes,
|
||||
employee_connection,
|
||||
require_role,
|
||||
)
|
||||
from shopdb.api import Setting
|
||||
|
||||
from ..models import DirectoryEmployee
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
employees_bp = Blueprint('employees', __name__)
|
||||
|
||||
# Columns safe to expose to the directory/recognition UI
|
||||
_FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture'
|
||||
|
||||
# Uploaded self-hosted employee photos live in the instance dir and are served
|
||||
# publicly (kiosk recognition/recertification cards read them without auth).
|
||||
EMPLOYEE_PHOTO_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
||||
|
||||
# URL prefix a served upload resolves to (self-hosted mode).
|
||||
EMPLOYEE_PHOTO_URL_PREFIX = '/api/employees/photo/'
|
||||
|
||||
# URL prefix external HR relative picture paths resolve under. The HR employees
|
||||
# table stores Picture as a relative path (e.g. 'Support/210009518.png') that
|
||||
# the site serves from /static/employees/; this matches the shopfloor feed.
|
||||
EMPLOYEE_PHOTO_STATIC_PREFIX = '/static/employees/'
|
||||
|
||||
|
||||
def _employeephoto_dir():
|
||||
return os.path.join(current_app.instance_path, 'employeephotos')
|
||||
|
||||
|
||||
def _selfhosted():
|
||||
"""True when the directory is the app-owned table, not an external HR DB."""
|
||||
row = Setting.query.filter_by(key='employee_directory_mode').first()
|
||||
return (row.value if row and row.value else 'external').lower() == 'selfhosted'
|
||||
|
||||
|
||||
def _require_selfhosted():
|
||||
"""Guard for management endpoints - only valid in self-hosted mode."""
|
||||
if not _selfhosted():
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Directory is in external mode; manage people in the source HR database.',
|
||||
http_code=400)
|
||||
return None
|
||||
|
||||
|
||||
def _require_selfhosted_photo():
|
||||
"""Guard for photo write endpoints - 409 when the directory is external.
|
||||
|
||||
In external mode the photo is owned by the HR database (read-only
|
||||
pass-through), so upload/delete cannot apply here."""
|
||||
if not _selfhosted():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'Employee directory is external; photos are supplied by the HR '
|
||||
'database and cannot be uploaded or deleted here.',
|
||||
http_code=409)
|
||||
return None
|
||||
|
||||
|
||||
def _external_photo_url(picture):
|
||||
"""Turn an external HR Picture value into a usable URL, or None.
|
||||
|
||||
Absolute URLs and already-rooted paths pass through untouched (future
|
||||
full-URL HR feeds); a bare relative path is served under the static prefix
|
||||
(current WJ convention, e.g. 'Support/210009518.png')."""
|
||||
if not picture:
|
||||
return None
|
||||
text = str(picture).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.startswith(('http://', 'https://', '/')):
|
||||
return text
|
||||
return EMPLOYEE_PHOTO_STATIC_PREFIX + text
|
||||
|
||||
|
||||
def _hr_picture(sso):
|
||||
"""Raw Picture value for an SSO from the external HR directory. None on miss."""
|
||||
try:
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return row.get('Picture') if row else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_employee_photo_url(sso, external_picture=None):
|
||||
"""Single resolver both consumers share: the display photo URL for an SSO.
|
||||
|
||||
Self-hosted: the served upload URL when the employee has an uploaded photo,
|
||||
else None (the legacy Picture text field does not drive the photo here).
|
||||
External: the HR-supplied Picture resolved to a URL (pass external_picture to
|
||||
avoid a re-query), else None. Returns None on any miss or bad SSO."""
|
||||
if sso is None or not str(sso).isdigit():
|
||||
return None
|
||||
if _selfhosted():
|
||||
emp = db.session.get(DirectoryEmployee, int(sso))
|
||||
if emp and emp.photofilename:
|
||||
return EMPLOYEE_PHOTO_URL_PREFIX + emp.photofilename
|
||||
return None
|
||||
picture = external_picture if external_picture is not None else _hr_picture(sso)
|
||||
return _external_photo_url(picture)
|
||||
|
||||
|
||||
def _with_photo_url(employee):
|
||||
"""Add the resolved photourl to an employee dict (self-hosted or external)."""
|
||||
employee['photourl'] = resolve_employee_photo_url(
|
||||
employee.get('SSO'), employee.get('Picture'))
|
||||
return employee
|
||||
|
||||
|
||||
@employees_bp.route('/search', methods=['GET'])
|
||||
def search_employees():
|
||||
"""
|
||||
Search employees by name.
|
||||
|
||||
Query parameters:
|
||||
- q: Search query (searches first and last name)
|
||||
- limit: Max results (default 10)
|
||||
"""
|
||||
query = request.args.get('q', '').strip()
|
||||
limit = min(int(request.args.get('limit', 10)), 50)
|
||||
|
||||
if len(query) < 2:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Search query must be at least 2 characters'
|
||||
)
|
||||
|
||||
if _selfhosted():
|
||||
term = f'%{query}%'
|
||||
rows = (DirectoryEmployee.query
|
||||
.filter(db.or_(DirectoryEmployee.firstname.ilike(term),
|
||||
DirectoryEmployee.lastname.ilike(term),
|
||||
db.cast(DirectoryEmployee.sso, db.String).ilike(term)))
|
||||
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname)
|
||||
.limit(limit).all())
|
||||
return success_response([_with_photo_url(e.to_dict()) for e in rows])
|
||||
|
||||
try:
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'''
|
||||
SELECT {_FIELDS}
|
||||
FROM employees
|
||||
WHERE First_Name LIKE %s
|
||||
OR Last_Name LIKE %s
|
||||
OR CAST(SSO AS CHAR) LIKE %s
|
||||
ORDER BY Last_Name, First_Name
|
||||
LIMIT %s
|
||||
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
|
||||
employees = cur.fetchall()
|
||||
conn.close()
|
||||
return success_response([_with_photo_url(e) for e in employees])
|
||||
except Exception:
|
||||
logger.exception('Employee search failed')
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Employee lookup failed',
|
||||
http_code=500
|
||||
)
|
||||
|
||||
|
||||
@employees_bp.route('/lookup/<sso>', methods=['GET'])
|
||||
def lookup_employee(sso):
|
||||
"""Look up a single employee by SSO."""
|
||||
if not sso.isdigit():
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'SSO must be numeric'
|
||||
)
|
||||
|
||||
if _selfhosted():
|
||||
emp = db.session.get(DirectoryEmployee, int(sso))
|
||||
if not emp:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Employee with SSO {sso} not found', http_code=404)
|
||||
return success_response(_with_photo_url(emp.to_dict()))
|
||||
|
||||
try:
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f'SELECT {_FIELDS} FROM employees WHERE SSO = %s',
|
||||
(int(sso),)
|
||||
)
|
||||
employee = cur.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not employee:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Employee with SSO {sso} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(_with_photo_url(employee))
|
||||
except Exception:
|
||||
logger.exception('Employee lookup failed for SSO %s', sso)
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Employee lookup failed',
|
||||
http_code=500
|
||||
)
|
||||
|
||||
|
||||
@employees_bp.route('/lookup', methods=['GET'])
|
||||
def lookup_employees():
|
||||
"""
|
||||
Look up multiple employees by SSO list.
|
||||
|
||||
Query parameters:
|
||||
- sso: Comma-separated list of SSOs
|
||||
"""
|
||||
sso_list = request.args.get('sso', '')
|
||||
ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()]
|
||||
|
||||
if not ssos:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'At least one valid SSO is required'
|
||||
)
|
||||
|
||||
if _selfhosted():
|
||||
rows = DirectoryEmployee.query.filter(
|
||||
DirectoryEmployee.sso.in_([int(s) for s in ssos])).all()
|
||||
employees = [_with_photo_url(e.to_dict()) for e in rows]
|
||||
names = ', '.join(f"{e['First_Name'].strip()} {e['Last_Name'].strip()}"
|
||||
for e in employees)
|
||||
return success_response({'employees': employees, 'names': names})
|
||||
|
||||
try:
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
placeholders = ','.join(['%s'] * len(ssos))
|
||||
cur.execute(
|
||||
f'SELECT {_FIELDS} FROM employees WHERE SSO IN ({placeholders})',
|
||||
[int(s) for s in ssos]
|
||||
)
|
||||
employees = cur.fetchall()
|
||||
conn.close()
|
||||
|
||||
employees = [_with_photo_url(e) for e in employees]
|
||||
names = ', '.join(
|
||||
f"{e['First_Name'].strip()} {e['Last_Name'].strip()}"
|
||||
for e in employees
|
||||
)
|
||||
|
||||
return success_response({
|
||||
'employees': employees,
|
||||
'names': names
|
||||
})
|
||||
except Exception:
|
||||
logger.exception('Employee multi-lookup failed')
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Employee lookup failed',
|
||||
http_code=500
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Self-hosted directory management (only when directory_mode=selfhosted)
|
||||
# =============================================================================
|
||||
|
||||
@employees_bp.route('/directory', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_directory():
|
||||
"""Full self-hosted directory (for the management page)."""
|
||||
guard = _require_selfhosted()
|
||||
if guard:
|
||||
return guard
|
||||
rows = (DirectoryEmployee.query
|
||||
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).all())
|
||||
return success_response([_with_photo_url(e.to_dict()) for e in rows])
|
||||
|
||||
|
||||
def _employee_from_payload(data):
|
||||
"""Build kwargs from a payload accepting either external-style (SSO,
|
||||
First_Name...) or plain (sso, firstname...) keys."""
|
||||
def pick(*keys):
|
||||
for key in keys:
|
||||
if data.get(key) not in (None, ''):
|
||||
return data.get(key)
|
||||
return None
|
||||
return {
|
||||
'sso': pick('sso', 'SSO'),
|
||||
'firstname': pick('firstname', 'First_Name'),
|
||||
'lastname': pick('lastname', 'Last_Name'),
|
||||
'team': pick('team', 'Team'),
|
||||
'role': pick('role', 'Role'),
|
||||
'picture': pick('picture', 'Picture'),
|
||||
}
|
||||
|
||||
|
||||
@employees_bp.route('/directory', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def create_directory_employee():
|
||||
guard = _require_selfhosted()
|
||||
if guard:
|
||||
return guard
|
||||
fields = _employee_from_payload(request.get_json() or {})
|
||||
if not (fields['sso'] and fields['firstname'] and fields['lastname']):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'sso, firstname and lastname are required')
|
||||
try:
|
||||
sso = int(fields['sso'])
|
||||
except (ValueError, TypeError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'sso must be numeric')
|
||||
if db.session.get(DirectoryEmployee, sso):
|
||||
return error_response(ErrorCodes.CONFLICT, f'SSO {sso} already exists', http_code=409)
|
||||
emp = DirectoryEmployee(sso=sso, firstname=fields['firstname'], lastname=fields['lastname'],
|
||||
team=fields['team'], role=fields['role'], picture=fields['picture'])
|
||||
db.session.add(emp)
|
||||
db.session.commit()
|
||||
return success_response(emp.to_dict(), message='Employee added', http_code=201)
|
||||
|
||||
|
||||
@employees_bp.route('/directory/<int:sso>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def update_directory_employee(sso):
|
||||
guard = _require_selfhosted()
|
||||
if guard:
|
||||
return guard
|
||||
emp = db.session.get(DirectoryEmployee, sso)
|
||||
if not emp:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
|
||||
fields = _employee_from_payload(request.get_json() or {})
|
||||
if fields['firstname']:
|
||||
emp.firstname = fields['firstname']
|
||||
if fields['lastname']:
|
||||
emp.lastname = fields['lastname']
|
||||
for key in ('team', 'role', 'picture'):
|
||||
if key in (request.get_json() or {}) or fields[key] is not None:
|
||||
setattr(emp, key, fields[key])
|
||||
db.session.commit()
|
||||
return success_response(emp.to_dict(), message='Employee updated')
|
||||
|
||||
|
||||
@employees_bp.route('/directory/<int:sso>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def delete_directory_employee(sso):
|
||||
guard = _require_selfhosted()
|
||||
if guard:
|
||||
return guard
|
||||
emp = db.session.get(DirectoryEmployee, sso)
|
||||
if not emp:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
|
||||
db.session.delete(emp)
|
||||
db.session.commit()
|
||||
return success_response(message='Employee removed')
|
||||
|
||||
|
||||
@employees_bp.route('/directory/import', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def import_directory():
|
||||
"""Bulk upsert from CSV. Accepts headers SSO,First_Name,Last_Name,Team,Role,
|
||||
Picture (case-insensitive; sso/firstname/... also accepted)."""
|
||||
guard = _require_selfhosted()
|
||||
if guard:
|
||||
return guard
|
||||
text = ''
|
||||
if 'file' in request.files:
|
||||
text = request.files['file'].read().decode('utf-8-sig', errors='replace')
|
||||
else:
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get('csv', '')
|
||||
if not text.strip():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No CSV provided')
|
||||
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
# Normalize headers to lower for tolerant matching.
|
||||
added = updated = skipped = 0
|
||||
for raw in reader:
|
||||
row = {(k or '').strip().lower(): (v or '').strip() for k, v in raw.items()}
|
||||
sso_raw = row.get('sso') or row.get('sso ')
|
||||
first = row.get('first_name') or row.get('firstname')
|
||||
last = row.get('last_name') or row.get('lastname')
|
||||
if not (sso_raw and sso_raw.isdigit() and first and last):
|
||||
skipped += 1
|
||||
continue
|
||||
sso = int(sso_raw)
|
||||
team = row.get('team') or None
|
||||
role = row.get('role') or None
|
||||
picture = row.get('picture') or None
|
||||
emp = db.session.get(DirectoryEmployee, sso)
|
||||
if emp:
|
||||
emp.firstname, emp.lastname, emp.team, emp.role, emp.picture = first, last, team, role, picture
|
||||
updated += 1
|
||||
else:
|
||||
db.session.add(DirectoryEmployee(sso=sso, firstname=first, lastname=last,
|
||||
team=team, role=role, picture=picture))
|
||||
added += 1
|
||||
db.session.commit()
|
||||
return success_response({'added': added, 'updated': updated, 'skipped': skipped},
|
||||
message=f'Import done: {added} added, {updated} updated, {skipped} skipped.')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Self-hosted employee photos (upload/replace/delete + public serve)
|
||||
# =============================================================================
|
||||
|
||||
@employees_bp.route('/<int:sso>/photo', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def upload_employee_photo(sso):
|
||||
"""Upload (or replace) the photo for a self-hosted directory employee.
|
||||
|
||||
multipart/form-data: file=<image>. Saves to the instance employeephotos dir
|
||||
as photo-<sso><ext> (one photo per person) and points photofilename at it.
|
||||
Re-upload replaces the old file even when the extension changes. External
|
||||
mode is a 409 (photo is owned by the HR database)."""
|
||||
guard = _require_selfhosted_photo()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
emp = db.session.get(DirectoryEmployee, sso)
|
||||
if not emp:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
|
||||
|
||||
upload = request.files.get('file')
|
||||
if not upload or not upload.filename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
ext = os.path.splitext(upload.filename)[1].lower()
|
||||
if ext not in EMPLOYEE_PHOTO_EXTENSIONS:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unsupported image type {ext}')
|
||||
|
||||
photodir = _employeephoto_dir()
|
||||
os.makedirs(photodir, exist_ok=True)
|
||||
|
||||
# Wipe any prior photo-<sso>.* so a new extension does not orphan the old one.
|
||||
for old in glob.glob(os.path.join(photodir, secure_filename(f'photo-{sso}') + '.*')):
|
||||
os.remove(old)
|
||||
|
||||
filename = secure_filename(f'photo-{sso}{ext}')
|
||||
upload.save(os.path.join(photodir, filename))
|
||||
|
||||
emp.photofilename = filename
|
||||
db.session.commit()
|
||||
|
||||
return success_response(_with_photo_url(emp.to_dict()), message='Employee photo uploaded')
|
||||
|
||||
|
||||
@employees_bp.route('/photo/<path:filename>', methods=['GET'])
|
||||
def serve_employee_photo(filename):
|
||||
"""Serve an uploaded employee photo (public - kiosk cards read it)."""
|
||||
return send_from_directory(_employeephoto_dir(), filename)
|
||||
|
||||
|
||||
@employees_bp.route('/<int:sso>/photo', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def delete_employee_photo(sso):
|
||||
"""Clear an employee photo and delete the uploaded file. External mode 409s."""
|
||||
guard = _require_selfhosted_photo()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
emp = db.session.get(DirectoryEmployee, sso)
|
||||
if not emp:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404)
|
||||
|
||||
if emp.photofilename:
|
||||
# secure_filename strips any traversal; the file lives in our dir only.
|
||||
path = os.path.join(_employeephoto_dir(), secure_filename(emp.photofilename))
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
emp.photofilename = None
|
||||
db.session.commit()
|
||||
|
||||
return success_response(_with_photo_url(emp.to_dict()), message='Employee photo removed')
|
||||
15
plugins/employees/manifest.json
Normal file
15
plugins/employees/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "employees",
|
||||
"version": "1.0.0",
|
||||
"description": "Read-only employee directory lookup (separate HR database)",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/employees",
|
||||
"provides": {
|
||||
"features": [
|
||||
"employee_directory"
|
||||
]
|
||||
},
|
||||
"default_enabled": false
|
||||
}
|
||||
14
plugins/employees/migrations/env.py
Normal file
14
plugins/employees/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the employees plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which
|
||||
filters the metadata to this plugin's tables and drives Alembic against the
|
||||
per-plugin version table alembic_version_employees. See ADR-008 for the
|
||||
ownership cutover between the core chain and per-plugin chains.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'employees'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/employees/migrations/script.py.mako
Normal file
24
plugins/employees/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""employees plugin anchor (ownership cutover).
|
||||
|
||||
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
|
||||
already created every table this plugin owns at the cutover point, so there is
|
||||
nothing to build here. This revision gives the plugin's own chain a base that
|
||||
`flask plugin upgrade-all` can stamp into alembic_version_employees. From this
|
||||
anchor forward, new employees schema changes land as 000N revisions in this
|
||||
directory, never in the core chain. See ADR-008.
|
||||
"""
|
||||
from alembic import op # noqa: F401
|
||||
import sqlalchemy as sa # noqa: F401
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'employees0001anchor'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No-op: the core chain owns every table that exists at the cutover.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No-op: this anchor never unwinds core-owned tables.
|
||||
pass
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add photofilename to directoryemployees (self-hosted employee photos).
|
||||
|
||||
The core chain (7d16_directoryemployees) created directoryemployees WITHOUT a
|
||||
photofilename column. This plugin revision adds it, so BOTH fresh installs (core
|
||||
chain builds the table, then this adds the column) and existing installs get it.
|
||||
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
|
||||
column already exists (e.g. a test DB built by db.create_all() from the model).
|
||||
|
||||
Revision ID: employees0002photo
|
||||
Revises: employees0001anchor
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'employees0002photo'
|
||||
down_revision = 'employees0001anchor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'directoryemployees' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('directoryemployees')}
|
||||
if 'photofilename' not in cols:
|
||||
op.add_column('directoryemployees',
|
||||
sa.Column('photofilename', sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'directoryemployees' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('directoryemployees')}
|
||||
if 'photofilename' in cols:
|
||||
op.drop_column('directoryemployees', 'photofilename')
|
||||
5
plugins/employees/models/__init__.py
Normal file
5
plugins/employees/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Employees plugin models."""
|
||||
|
||||
from .directory_employee import DirectoryEmployee
|
||||
|
||||
__all__ = ['DirectoryEmployee']
|
||||
43
plugins/employees/models/directory_employee.py
Normal file
43
plugins/employees/models/directory_employee.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Self-hosted employee directory.
|
||||
|
||||
For sites with no external HR database. When employee_directory_mode is
|
||||
'selfhosted', the employee lookup APIs read this app-owned table instead of the
|
||||
external directory, and the directory is managed in-app (CRUD + CSV import).
|
||||
|
||||
to_dict emits the same keys the external contract returns (SSO, First_Name,
|
||||
Last_Name, Team, Role, Picture) so the frontend and both modes share one shape.
|
||||
|
||||
photofilename holds the basename of an uploaded photo (photo-<sso><ext>) served
|
||||
from instance/employeephotos/. It is distinct from the legacy Picture text
|
||||
field: in self-hosted mode the displayed photo comes from uploads (photofilename)
|
||||
via the shared resolver, not from Picture.
|
||||
"""
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
class DirectoryEmployee(db.Model):
|
||||
__tablename__ = 'directoryemployees'
|
||||
|
||||
sso = db.Column(db.Integer, primary_key=True, autoincrement=False)
|
||||
firstname = db.Column(db.String(100), nullable=False)
|
||||
lastname = db.Column(db.String(100), nullable=False)
|
||||
team = db.Column(db.String(100))
|
||||
role = db.Column(db.String(100))
|
||||
picture = db.Column(db.String(255))
|
||||
# basename of an uploaded photo (photo-<sso><ext>); None when no upload
|
||||
photofilename = db.Column(db.String(255))
|
||||
|
||||
def to_dict(self):
|
||||
# Keys match the external employees contract the frontend consumes.
|
||||
# photofilename is extra (self-hosted upload); the resolved display URL
|
||||
# is added as photourl by the API layer via resolve_employee_photo_url.
|
||||
return {
|
||||
'SSO': self.sso,
|
||||
'First_Name': self.firstname,
|
||||
'Last_Name': self.lastname,
|
||||
'Team': self.team,
|
||||
'Role': self.role,
|
||||
'Picture': self.picture,
|
||||
'photofilename': self.photofilename,
|
||||
}
|
||||
98
plugins/employees/plugin.py
Normal file
98
plugins/employees/plugin.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Employees plugin main class.
|
||||
|
||||
Blueprint-only plugin: a read-only employee directory lookup over a separate HR
|
||||
database. No model (the directory is an external DB) and no AssetType. The
|
||||
connection helper (employee_connection) stays core infrastructure in shopdb.api,
|
||||
shared with search and the notifications shopfloor feed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
from .api import employees_bp
|
||||
from .models import DirectoryEmployee
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmployeesPlugin(BasePlugin):
|
||||
"""Employees plugin - read-only HR directory lookup."""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'employees'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Read-only employee directory lookup'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/employees'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return employees_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Self-hosted directory table (used when directory_mode=selfhosted).
|
||||
External mode reads a separate DB via employee_connection instead."""
|
||||
return [DirectoryEmployee]
|
||||
|
||||
def get_provisioning_note(self) -> Optional[Dict]:
|
||||
return {
|
||||
'mode_setting': 'employee_directory_mode',
|
||||
'tables': ['directoryemployees'],
|
||||
'note': ('Enabling this in self-hosted mode creates a '
|
||||
'"directoryemployees" table in the shopdb database (SSO, '
|
||||
'First_Name, Last_Name, Team, Role, Picture). It is '
|
||||
'referenced across the site: employee search, notification '
|
||||
'recognition (name + photo on the shopfloor/lobby displays), '
|
||||
'and USB check-in/out name resolution. Manage people under '
|
||||
'Settings > Employee Directory, or point at an existing HR '
|
||||
'database instead (external mode).'),
|
||||
'docs': 'plugins/employees/README.md',
|
||||
}
|
||||
|
||||
def get_config_schema(self) -> List[Dict]:
|
||||
"""Employee directory DB connection. Host/name/user are settings the
|
||||
wizard can edit; the password stays in .env (emitted, not stored)."""
|
||||
return [
|
||||
{'key': 'employee_db_host', 'label': 'Employee DB host', 'type': 'text',
|
||||
'secret': False, 'default': 'localhost',
|
||||
'help': 'This DB must expose an "employees" table or view with columns '
|
||||
'SSO, First_Name, Last_Name, Team, Role, Picture. '
|
||||
'See plugins/employees/README.md.'},
|
||||
{'key': 'employee_db_name', 'label': 'Employee DB name', 'type': 'text',
|
||||
'secret': False, 'default': 'wjf_employees'},
|
||||
{'key': 'employee_db_user', 'label': 'Employee DB user', 'type': 'text',
|
||||
'secret': False},
|
||||
{'key': 'employee_db_password', 'label': 'Employee DB password', 'type': 'password',
|
||||
'secret': True, 'envvar': 'EMPLOYEE_DB_PASSWORD',
|
||||
'help': 'Stored in .env, not the database. The wizard shows the line to paste.'},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Employees plugin initialized (v{self.meta.version})")
|
||||
5
plugins/geenforce/__init__.py
Normal file
5
plugins/geenforce/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""GE-Enforce manifest-store plugin package."""
|
||||
|
||||
from .plugin import GeEnforcePlugin
|
||||
|
||||
__all__ = ['GeEnforcePlugin']
|
||||
5
plugins/geenforce/api/__init__.py
Normal file
5
plugins/geenforce/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""GE-Enforce plugin API blueprint."""
|
||||
|
||||
from .routes import geenforce_bp
|
||||
|
||||
__all__ = ['geenforce_bp']
|
||||
712
plugins/geenforce/api/routes.py
Normal file
712
plugins/geenforce/api/routes.py
Normal file
@@ -0,0 +1,712 @@
|
||||
"""GE-Enforce plugin API.
|
||||
|
||||
Two audiences:
|
||||
- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest.
|
||||
Full CRUD + publish lands in P2; this is the P1/first-slice read surface.
|
||||
- Client (service token, geenforce.fetch scope): GET /manifest serves the
|
||||
CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the
|
||||
collector's managed-token pattern (X-API-Key or Bearer PAT).
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
service_token_authorized, Setting, Application,
|
||||
)
|
||||
|
||||
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||
|
||||
from ..models import (
|
||||
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
||||
ManifestEnforcementReport, ManifestPayload, ENTRY_TYPES, PHASES,
|
||||
)
|
||||
from ..serializer import scope_to_manifest, entry_to_dict
|
||||
from ..importer import build_entry, populate_entry
|
||||
from ..filters import (
|
||||
entry_applies, matches_pctype, matches_hostname, matches_machinenumber,
|
||||
matches_cmmversion,
|
||||
)
|
||||
from .. import service
|
||||
|
||||
geenforce_bp = Blueprint('geenforce', __name__)
|
||||
|
||||
FETCH_SCOPE = 'geenforce.fetch'
|
||||
REPORT_SCOPE = 'geenforce.report'
|
||||
|
||||
|
||||
def _require_service_token(scope):
|
||||
"""Decorator factory: require a managed service token scoped for `scope`.
|
||||
|
||||
Tokens are the only client auth path (the client kit provisions a
|
||||
geenforce.fetch/report token). No env-key fallback: it was never wired into
|
||||
config and an unscoped shared key is a needless backdoor.
|
||||
"""
|
||||
def wrapper(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if service_token_authorized(scope):
|
||||
return f(*args, **kwargs)
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
return decorated
|
||||
return wrapper
|
||||
|
||||
|
||||
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
||||
require_report_token = _require_service_token(REPORT_SCOPE)
|
||||
|
||||
|
||||
# -- client-facing endpoint ---------------------------------------------------
|
||||
|
||||
@geenforce_bp.route('/manifest', methods=['GET'])
|
||||
@require_fetch_token
|
||||
def get_manifest():
|
||||
"""Serve the current published manifest for a scope (fat-client: full scope).
|
||||
|
||||
Query: pctype (=scopename, required), phase (default runtime). The engine
|
||||
filters client-side, matching today, so subtype/hostname/machinenumber/
|
||||
cmmversion are accepted but not applied here.
|
||||
"""
|
||||
scopename = (request.args.get('pctype') or '').strip()
|
||||
phase = (request.args.get('phase') or 'runtime').strip()
|
||||
if not scopename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'pctype is required', http_code=400)
|
||||
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No scope: {scopename}', http_code=404)
|
||||
|
||||
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
if not published:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'{scopename} has no published version',
|
||||
http_code=404)
|
||||
|
||||
etag = f'"{scope.scopeid}-v{published.versionnumber}"'
|
||||
if request.headers.get('If-None-Match') == etag:
|
||||
return Response(status=304, headers={'ETag': etag})
|
||||
return Response(published.manifestjson, mimetype='application/json',
|
||||
headers={'ETag': etag,
|
||||
'X-Manifest-Version': str(published.versionnumber)})
|
||||
|
||||
|
||||
# -- client reporting (observed state) ----------------------------------------
|
||||
|
||||
@geenforce_bp.route('/report', methods=['POST'])
|
||||
@require_report_token
|
||||
def post_report():
|
||||
"""Record one PC's enforcement cycle: applied version (did it receive the
|
||||
update?) + per-entry self-heal outcomes (installed/skipped/failed)."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if not (payload.get('hostname') or '').strip():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname is required', http_code=400)
|
||||
try:
|
||||
report = service.record_enforcement_report(payload)
|
||||
db.session.commit()
|
||||
except ValueError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc),
|
||||
http_code=400)
|
||||
return success_response({'reportid': report.reportid,
|
||||
'status': report.status}, message='recorded')
|
||||
|
||||
|
||||
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def list_scopes():
|
||||
"""List imaging PC-type scopes with entry + published-version counts."""
|
||||
scopes = ManifestScope.query.order_by(
|
||||
ManifestScope.phase, ManifestScope.scopename).all()
|
||||
data = []
|
||||
for scope in scopes:
|
||||
current = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
data.append({
|
||||
'scopeid': scope.scopeid,
|
||||
'scopename': scope.scopename,
|
||||
'phase': scope.phase,
|
||||
'manifestversion': scope.manifestversion,
|
||||
'computertypeid': scope.computertypeid,
|
||||
'measuringtooltypeid': scope.measuringtooltypeid,
|
||||
'iscommon': scope.iscommon,
|
||||
'entrycount': len(scope.entries),
|
||||
'publishedversion': current.versionnumber if current else None,
|
||||
})
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def preview_scope(scopeid):
|
||||
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
|
||||
http_code=404)
|
||||
return success_response({'scopename': scope.scopename,
|
||||
'manifest': scope_to_manifest(scope)})
|
||||
|
||||
|
||||
@geenforce_bp.route('/applications', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def list_applications():
|
||||
"""The core Applications catalog (id + name) for the curated entry->app link
|
||||
picker in the entry editor."""
|
||||
apps = Application.query.filter_by(isactive=True).order_by(
|
||||
Application.appname).all()
|
||||
return success_response([{'appid': a.appid, 'appname': a.appname}
|
||||
for a in apps])
|
||||
|
||||
|
||||
# -- scope CRUD (geenforce.manage) --------------------------------------------
|
||||
|
||||
def _scope_summary(scope):
|
||||
current = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
return {
|
||||
'scopeid': scope.scopeid,
|
||||
'scopename': scope.scopename,
|
||||
'phase': scope.phase,
|
||||
'manifestversion': scope.manifestversion,
|
||||
'description': scope.description,
|
||||
'computertypeid': scope.computertypeid,
|
||||
'measuringtooltypeid': scope.measuringtooltypeid,
|
||||
'iscommon': scope.iscommon,
|
||||
'entrycount': len(scope.entries),
|
||||
'publishedversion': current.versionnumber if current else None,
|
||||
}
|
||||
|
||||
|
||||
def _entry_payload(entry):
|
||||
"""Manifest-entry dict with entryid + sortorder + the curated app link.
|
||||
|
||||
appid/appname are shopdb metadata (not manifest fields), added alongside the
|
||||
rendered manifest entry for the editor.
|
||||
"""
|
||||
data = {'entryid': entry.entryid, 'sortorder': entry.sortorder,
|
||||
'appid': entry.appid, 'appname': None}
|
||||
if entry.appid:
|
||||
app = db.session.get(Application, entry.appid)
|
||||
data['appname'] = app.appname if app else None
|
||||
data.update(entry_to_dict(entry))
|
||||
# Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor.
|
||||
data['payloadsource'] = entry.payloadsource
|
||||
data['payloadref'] = entry.payloadref
|
||||
data['payloadsha256'] = entry.payloadsha256
|
||||
data['haspayload'] = (entry.payloadsource == 'inline'
|
||||
and entry.payloadsha256 is not None)
|
||||
return data
|
||||
|
||||
|
||||
def _apply_app_link(entry, payload):
|
||||
"""Set the optional curated appid from the payload (shopdb metadata, not a
|
||||
manifest field, so handled outside populate_entry). Ignores an unknown id."""
|
||||
if 'appid' not in payload:
|
||||
return
|
||||
appid = payload.get('appid')
|
||||
if appid in (None, '', 0):
|
||||
entry.appid = None
|
||||
return
|
||||
# Ignore a non-numeric id rather than 500 (matches the docstring contract).
|
||||
try:
|
||||
appid = int(appid)
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if db.session.get(Application, appid):
|
||||
entry.appid = appid
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def create_scope():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
scopename = (payload.get('scopename') or '').strip()
|
||||
phase = (payload.get('phase') or 'runtime').strip()
|
||||
if not scopename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'scopename is required', http_code=400)
|
||||
if phase not in PHASES:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'phase must be one of {PHASES}', http_code=400)
|
||||
if ManifestScope.query.filter_by(scopename=scopename, phase=phase).first():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'scope already exists', http_code=400)
|
||||
scope = ManifestScope(
|
||||
scopename=scopename, phase=phase,
|
||||
manifestversion=str(payload.get('manifestversion', '1.0')),
|
||||
description=payload.get('description'),
|
||||
computertypeid=payload.get('computertypeid'),
|
||||
measuringtooltypeid=payload.get('measuringtooltypeid'),
|
||||
iscommon=bool(payload.get('iscommon', scopename == 'common')))
|
||||
db.session.add(scope)
|
||||
db.session.commit()
|
||||
return success_response(_scope_summary(scope), http_code=201)
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def get_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
data = _scope_summary(scope)
|
||||
data['entries'] = [_entry_payload(e) for e in scope.entries]
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def update_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
payload = request.get_json(silent=True) or {}
|
||||
for field in ('description', 'computertypeid', 'measuringtooltypeid'):
|
||||
if field in payload:
|
||||
setattr(scope, field, payload[field])
|
||||
if 'manifestversion' in payload:
|
||||
scope.manifestversion = str(payload['manifestversion'])
|
||||
if 'iscommon' in payload:
|
||||
scope.iscommon = bool(payload['iscommon'])
|
||||
db.session.commit()
|
||||
return success_response(_scope_summary(scope))
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def delete_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
db.session.delete(scope)
|
||||
db.session.commit()
|
||||
return success_response({'deleted': scopeid})
|
||||
|
||||
|
||||
# -- entry CRUD (geenforce.manage) --------------------------------------------
|
||||
|
||||
def _validate_entry(payload):
|
||||
"""Return an error string, or None if the entry payload is valid."""
|
||||
if not (payload.get('Name') or '').strip():
|
||||
return 'entry Name is required'
|
||||
if payload.get('Type') not in ENTRY_TYPES:
|
||||
return f'entry Type must be one of {ENTRY_TYPES}'
|
||||
return None
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def create_entry(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
payload = request.get_json(silent=True) or {}
|
||||
invalid = _validate_entry(payload)
|
||||
if invalid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
|
||||
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
|
||||
entry = build_entry(payload, nextorder)
|
||||
_apply_app_link(entry, payload)
|
||||
scope.entries.append(entry)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'an entry with that Name already exists in this scope',
|
||||
http_code=400)
|
||||
return success_response(_entry_payload(entry), http_code=201)
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def update_entry(entryid):
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
if not entry:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
||||
payload = request.get_json(silent=True) or {}
|
||||
invalid = _validate_entry(payload)
|
||||
if invalid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
|
||||
# Free the one-to-one InUseCheck (unique entryid) before populate re-inserts
|
||||
# it, so the replacement does not collide with the old row mid-flush.
|
||||
if entry.inusecheck is not None:
|
||||
db.session.delete(entry.inusecheck)
|
||||
entry.inusecheck = None
|
||||
db.session.flush()
|
||||
populate_entry(entry, payload)
|
||||
_apply_app_link(entry, payload)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'an entry with that Name already exists in this scope',
|
||||
http_code=400)
|
||||
return success_response(_entry_payload(entry))
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def delete_entry(entryid):
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
if not entry:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
||||
db.session.delete(entry)
|
||||
db.session.commit()
|
||||
return success_response({'deleted': entryid})
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def reorder_entries(scopeid):
|
||||
"""Set entry order from a list of entryids (the ordering contract)."""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
order = (request.get_json(silent=True) or {}).get('order') or []
|
||||
owned = {e.entryid: e for e in scope.entries}
|
||||
if set(order) != set(owned):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'order must list exactly this scope\'s entry ids',
|
||||
http_code=400)
|
||||
for position, entryid in enumerate(order):
|
||||
owned[entryid].sortorder = position
|
||||
db.session.commit()
|
||||
return success_response({'order': order})
|
||||
|
||||
|
||||
# -- simulate: "what would this PC get" ---------------------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def simulate_scope(scopeid):
|
||||
"""Which draft entries apply to a given machine profile, and why the rest
|
||||
are filtered out (reuses the engine-mirror filters)."""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
profile = {
|
||||
'pctype': request.args.get('pctype') or scope.scopename,
|
||||
'subtype': request.args.get('subtype'),
|
||||
'hostname': request.args.get('hostname'),
|
||||
'machinenumber': request.args.get('machinenumber'),
|
||||
'cmmversion': request.args.get('cmmversion'),
|
||||
'phase': scope.phase,
|
||||
}
|
||||
applied, filtered = [], []
|
||||
for entry in scope.entries:
|
||||
entry_dict = entry_to_dict(entry)
|
||||
if entry_applies(entry_dict, profile):
|
||||
applied.append(entry.name)
|
||||
else:
|
||||
reasons = []
|
||||
strict_allowed = profile.get('phase') == 'preinstall'
|
||||
if not matches_pctype(entry_dict, profile['pctype'],
|
||||
profile['subtype'], strict_allowed):
|
||||
reasons.append('PCTypes')
|
||||
if not matches_hostname(entry_dict, profile['hostname']):
|
||||
reasons.append('TargetHostnames')
|
||||
if not matches_machinenumber(entry_dict, profile['machinenumber']):
|
||||
reasons.append('TargetMachineNumbers')
|
||||
if not matches_cmmversion(entry_dict, profile['cmmversion']):
|
||||
reasons.append('_CmmVersion')
|
||||
filtered.append({'name': entry.name, 'filteredby': reasons})
|
||||
return success_response({'profile': profile, 'applied': applied,
|
||||
'filtered': filtered})
|
||||
|
||||
|
||||
# -- compliance: "how much of the fleet has this app" -------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def scope_compliance(scopeid):
|
||||
"""Fleet-install coverage per app-linked entry (from collected PC data).
|
||||
|
||||
One row per entry that carries a curated appid; installed/version-match
|
||||
counts come from the computers plugin's ComputerInstalledApp. Degrades to
|
||||
null counts (computersplugin: false) when that plugin is absent.
|
||||
"""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
|
||||
http_code=404)
|
||||
return success_response(service.compliance_for_scope(scope))
|
||||
|
||||
|
||||
# -- inline payload upload / fetch (small scripts + configs) ------------------
|
||||
|
||||
PAYLOAD_MAX_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def upload_entry_payload(entryid):
|
||||
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
if not entry:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
||||
uploaded = request.files.get('file')
|
||||
if uploaded is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'a file is required', http_code=400)
|
||||
rawbytes = uploaded.read()
|
||||
if not rawbytes:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'payload is empty', http_code=400)
|
||||
if len(rawbytes) > PAYLOAD_MAX_BYTES:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'payload exceeds 1 MB limit', http_code=400)
|
||||
service.store_inline_payload(entry, uploaded.filename,
|
||||
uploaded.mimetype, rawbytes)
|
||||
db.session.commit()
|
||||
return success_response(_entry_payload(entry), http_code=201)
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def download_entry_payload(entryid):
|
||||
"""Return the stored inline payload bytes for an entry (404 if none)."""
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
if not entry:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
||||
payload = ManifestPayload.query.filter_by(entryid=entryid).first()
|
||||
if not payload:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload',
|
||||
http_code=404)
|
||||
return Response(
|
||||
payload.payloadbytes,
|
||||
mimetype=payload.contenttype or 'application/octet-stream',
|
||||
headers={'Content-Disposition':
|
||||
f'attachment; filename="{payload.filename}"'})
|
||||
|
||||
|
||||
# -- publish lifecycle (geenforce.publish) ------------------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def publish_scope_route(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
from flask_jwt_extended import get_jwt_identity
|
||||
notes = (request.get_json(silent=True) or {}).get('notes')
|
||||
try:
|
||||
publishedby = int(get_jwt_identity())
|
||||
except (TypeError, ValueError):
|
||||
publishedby = None
|
||||
version = service.publish_scope(scope.scopename, scope.phase,
|
||||
notes=notes, publishedby=publishedby)
|
||||
db.session.commit()
|
||||
return success_response({'versionnumber': version}, http_code=201)
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def list_versions(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
versions = ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scopeid).order_by(
|
||||
ManifestPublishedVersion.versionnumber.desc()).all()
|
||||
return success_response([{
|
||||
'versionnumber': v.versionnumber,
|
||||
'iscurrent': v.iscurrent,
|
||||
'publishedat': v.publishedat.isoformat() + 'Z' if v.publishedat else None,
|
||||
'publishedby': v.publishedby,
|
||||
'notes': v.notes,
|
||||
} for v in versions])
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
|
||||
methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def get_version(scopeid, versionnumber):
|
||||
version = ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scopeid, versionnumber=versionnumber).first()
|
||||
if not version:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such version',
|
||||
http_code=404)
|
||||
import json as _json
|
||||
return success_response({'versionnumber': version.versionnumber,
|
||||
'manifest': _json.loads(version.manifestjson)})
|
||||
|
||||
|
||||
@geenforce_bp.route('/config', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def get_config():
|
||||
"""Plugin config: the on-share export root (for export-to-share)."""
|
||||
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||
return success_response({'shareroot': setting.value if setting else ''})
|
||||
|
||||
|
||||
@geenforce_bp.route('/config', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def put_config():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(),
|
||||
valuetype='string', category='geenforce',
|
||||
description='On-share export root for GE-Enforce manifests')
|
||||
db.session.commit()
|
||||
return success_response({'shareroot': (payload.get('shareroot') or '').strip()})
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def export_share_route(scopeid):
|
||||
"""Write the scope's current published JSON to the configured share root
|
||||
(backing up the old file to _meta/history first)."""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||
shareroot = setting.value if setting else ''
|
||||
if not shareroot:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Configure the share root first', http_code=400)
|
||||
try:
|
||||
path = service.export_scope_to_share(scope.scopename, scope.phase,
|
||||
shareroot)
|
||||
except (ValueError, OSError) as exc:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
|
||||
return success_response({'path': path})
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def rollback_scope_route(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
versionnumber = (request.get_json(silent=True) or {}).get('versionnumber')
|
||||
if versionnumber is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'versionnumber is required', http_code=400)
|
||||
try:
|
||||
service.rollback_scope(scope.scopename, scope.phase, int(versionnumber))
|
||||
db.session.commit()
|
||||
except ValueError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
|
||||
return success_response({'versionnumber': int(versionnumber)})
|
||||
|
||||
|
||||
def _current_published_version(scopename, phase):
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
return None
|
||||
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
return published.versionnumber if published else None
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def list_reports():
|
||||
"""Latest enforcement report per PC (fleet compliance view).
|
||||
|
||||
Each row shows the applied vs latest published version (receivedlatest = the
|
||||
PC picked up the update) and the install/skip/fail counts + status.
|
||||
Optional filters: hostname, scopename.
|
||||
"""
|
||||
query = ManifestEnforcementReport.query.filter_by(iscurrent=True)
|
||||
hostname = request.args.get('hostname')
|
||||
scopename = request.args.get('scopename')
|
||||
if hostname:
|
||||
query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname))
|
||||
if scopename:
|
||||
query = query.filter_by(scopename=scopename)
|
||||
reports = query.order_by(
|
||||
ManifestEnforcementReport.receivedat.desc()).all()
|
||||
|
||||
latest_cache = {}
|
||||
data = []
|
||||
for report in reports:
|
||||
key = (report.scopename, report.phase)
|
||||
if key not in latest_cache:
|
||||
latest_cache[key] = _current_published_version(*key)
|
||||
latest = latest_cache[key]
|
||||
data.append({
|
||||
'reportid': report.reportid,
|
||||
'hostname': report.hostname,
|
||||
'scopename': report.scopename,
|
||||
'phase': report.phase,
|
||||
'appliedversion': report.appliedversion,
|
||||
'latestversion': latest,
|
||||
'receivedlatest': (latest is not None
|
||||
and report.appliedversion == latest),
|
||||
'enforcerversion': report.enforcerversion,
|
||||
'status': report.status,
|
||||
'installed': report.installedcount,
|
||||
'skipped': report.skippedcount,
|
||||
'failed': report.failedcount,
|
||||
'filtered': report.filteredcount,
|
||||
'lastcheckin': (report.lastcheckin.isoformat() + 'Z'
|
||||
if report.lastcheckin else None),
|
||||
'receivedat': (report.receivedat.isoformat() + 'Z'
|
||||
if report.receivedat else None),
|
||||
})
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def get_report(reportid):
|
||||
"""One report with per-entry outcomes (self-heal / failure detail)."""
|
||||
report = db.session.get(ManifestEnforcementReport, reportid)
|
||||
if not report:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such report',
|
||||
http_code=404)
|
||||
latest = _current_published_version(report.scopename, report.phase)
|
||||
return success_response({
|
||||
'reportid': report.reportid,
|
||||
'hostname': report.hostname,
|
||||
'scopename': report.scopename,
|
||||
'phase': report.phase,
|
||||
'appliedversion': report.appliedversion,
|
||||
'latestversion': latest,
|
||||
'receivedlatest': (latest is not None
|
||||
and report.appliedversion == latest),
|
||||
'status': report.status,
|
||||
'results': [{
|
||||
'entryname': r.entryname,
|
||||
'action': r.action,
|
||||
'selfhealed': r.selfhealed,
|
||||
'exitcode': r.exitcode,
|
||||
'message': r.message,
|
||||
} for r in report.results],
|
||||
})
|
||||
180
plugins/geenforce/client/Install-GEEnforce.ps1
Normal file
180
plugins/geenforce/client/Install-GEEnforce.ps1
Normal file
@@ -0,0 +1,180 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Provision a PC for GE-Enforce + shopdb: write its identity (so it knows its PC
|
||||
type and bay), deploy the shopdb client kit, and register the enforcement
|
||||
scheduled task. Site-neutral and imaging-path independent - run it from a PXE
|
||||
step, an OOBE provisioning package (ppkg), Intune, or by hand.
|
||||
|
||||
This is the "set up the PC to determine its PC type and such" step. There is no
|
||||
auto-detection: the caller supplies the type (and, where relevant, the bay
|
||||
machine number and CMM version), and this script writes the files/registry the
|
||||
GE-Enforce engine reads.
|
||||
|
||||
This script does NOT contain the GE-Enforce engine (Install-FromManifest.ps1)
|
||||
or dispatcher - those are the GE-Enforce framework's. Point at your copy with
|
||||
-EngineSource, or ensure it is already present under -InstallRoot\lib.
|
||||
|
||||
.PARAMETER PCType
|
||||
The imaging PC type = the manifest scope this PC runs. One of the scope names
|
||||
(e.g. gea-shopfloor-cmm, gea-shopfloor-collections) or a legacy alias the
|
||||
engine maps. Written to C:\Enrollment\pc-type.txt. REQUIRED.
|
||||
|
||||
.PARAMETER MachineNumber
|
||||
The bay machine number (for TargetMachineNumbers gates). Written to
|
||||
machine-number.txt as a fallback; the DNC registry MachineNo wins if present.
|
||||
|
||||
.PARAMETER CmmVersion
|
||||
CMM bays only: the resolved PC-DMIS version (2016/2019/2026) for _CmmVersion
|
||||
gating. Written to C:\Enrollment\cmm\version.txt. Omit for non-CMM PCs.
|
||||
|
||||
.PARAMETER CmmId
|
||||
CMM bays only: the bay id. Written to C:\Enrollment\cmm\cmmid.txt.
|
||||
|
||||
.PARAMETER ShareRoot
|
||||
The GE-Enforce share root (UNC), written into site-config.json as
|
||||
shopfloorShareRoot.
|
||||
|
||||
.PARAMETER Site
|
||||
Site name, written into site-config.json.
|
||||
|
||||
.PARAMETER ShopdbUrl
|
||||
shopdb base URL (scheme + host) for the fetch/report client. Written to
|
||||
HKLM:\SOFTWARE\GE\ShopDB\BaseUrl.
|
||||
|
||||
.PARAMETER ShopdbToken
|
||||
A geenforce.fetch (+ geenforce.report) managed service token. Written to
|
||||
HKLM:\SOFTWARE\GE\ShopDB\ApiToken. (In many sites Azure DSC provisions this
|
||||
instead - omit here if so.)
|
||||
|
||||
.PARAMETER EngineSource
|
||||
Optional path (folder or share) containing the GE-Enforce engine to copy in:
|
||||
expects GE-Enforce.ps1 and lib\Install-FromManifest.ps1. If omitted, the engine
|
||||
is assumed already present under -InstallRoot.
|
||||
|
||||
.PARAMETER InstallRoot
|
||||
Where the client kit + engine live on the PC. Default C:\ProgramData\GE-Enforce.
|
||||
|
||||
.PARAMETER TaskName
|
||||
Scheduled task name. Default 'GE-Enforce'.
|
||||
|
||||
.PARAMETER IntervalMinutes
|
||||
How often the enforcement task repeats. Default 5.
|
||||
|
||||
.PARAMETER NoTask
|
||||
Provision identity + kit only; do not register the scheduled task.
|
||||
|
||||
.EXAMPLE
|
||||
.\Install-GEEnforce.ps1 -PCType gea-shopfloor-cmm -MachineNumber 0615 `
|
||||
-CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" `
|
||||
-ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx `
|
||||
-EngineSource \\server\share\dt\shopfloor\common
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$PCType,
|
||||
[string]$MachineNumber,
|
||||
[string]$CmmVersion,
|
||||
[string]$CmmId,
|
||||
[string]$ShareRoot,
|
||||
[string]$Site,
|
||||
[string]$ShopdbUrl,
|
||||
[string]$ShopdbToken,
|
||||
[string]$EngineSource,
|
||||
[string]$InstallRoot = 'C:\ProgramData\GE-Enforce',
|
||||
[string]$EnrollmentRoot = 'C:\Enrollment',
|
||||
[string]$TaskName = 'GE-Enforce',
|
||||
[int]$IntervalMinutes = 5,
|
||||
[switch]$NoTask
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-TextFile {
|
||||
param([string]$Path, [string]$Value)
|
||||
$dir = Split-Path -Parent $Path
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
Set-Content -LiteralPath $Path -Value $Value -Encoding ascii -NoNewline
|
||||
Write-Host " wrote $Path"
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "GE-Enforce provisioning: PCType=$PCType"
|
||||
|
||||
# --- 1. Identity: what the PC is (what the engine reads) -----------------
|
||||
Write-TextFile (Join-Path $EnrollmentRoot 'pc-type.txt') $PCType
|
||||
if ($MachineNumber) { Write-TextFile (Join-Path $EnrollmentRoot 'machine-number.txt') $MachineNumber }
|
||||
if ($CmmVersion) { Write-TextFile (Join-Path $EnrollmentRoot 'cmm\version.txt') $CmmVersion }
|
||||
if ($CmmId) { Write-TextFile (Join-Path $EnrollmentRoot 'cmm\cmmid.txt') $CmmId }
|
||||
|
||||
$siteConfig = @{}
|
||||
if ($ShareRoot) { $siteConfig['shopfloorShareRoot'] = $ShareRoot }
|
||||
if ($Site) { $siteConfig['site'] = $Site }
|
||||
if ($siteConfig.Count) {
|
||||
$siteConfigPath = Join-Path $EnrollmentRoot 'site-config.json'
|
||||
$dir = Split-Path -Parent $siteConfigPath
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
($siteConfig | ConvertTo-Json) | Set-Content -LiteralPath $siteConfigPath -Encoding ascii
|
||||
Write-Host " wrote $siteConfigPath"
|
||||
}
|
||||
|
||||
# --- 2. shopdb client registry config ------------------------------------
|
||||
if ($ShopdbUrl -or $ShopdbToken) {
|
||||
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
|
||||
if ($ShopdbUrl) { Set-ItemProperty -Path $regPath -Name BaseUrl -Value $ShopdbUrl }
|
||||
if ($ShopdbToken) { Set-ItemProperty -Path $regPath -Name ApiToken -Value $ShopdbToken }
|
||||
Write-Host " wrote HKLM:\SOFTWARE\GE\ShopDB"
|
||||
}
|
||||
|
||||
# --- 3. Deploy the client kit (ships alongside this script) --------------
|
||||
if (-not (Test-Path $InstallRoot)) { New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null }
|
||||
foreach ($file in 'ShopdbEnforceClient.psm1', 'Invoke-ShopdbEnforce.ps1') {
|
||||
$src = Join-Path $PSScriptRoot $file
|
||||
if (Test-Path $src) {
|
||||
Copy-Item -Path $src -Destination (Join-Path $InstallRoot $file) -Force
|
||||
Write-Host " deployed $file"
|
||||
} else {
|
||||
Write-Warning "client kit file not found next to this script: $file"
|
||||
}
|
||||
}
|
||||
|
||||
# --- 4. Engine (referenced, not vendored) --------------------------------
|
||||
if ($EngineSource) {
|
||||
Copy-Item -Path (Join-Path $EngineSource 'GE-Enforce.ps1') `
|
||||
-Destination (Join-Path $InstallRoot 'GE-Enforce.ps1') -Force -ErrorAction SilentlyContinue
|
||||
$libDir = Join-Path $InstallRoot 'lib'
|
||||
if (-not (Test-Path $libDir)) { New-Item -ItemType Directory -Path $libDir -Force | Out-Null }
|
||||
Copy-Item -Path (Join-Path $EngineSource 'lib\Install-FromManifest.ps1') `
|
||||
-Destination (Join-Path $libDir 'Install-FromManifest.ps1') -Force -ErrorAction SilentlyContinue
|
||||
Write-Host " copied engine from $EngineSource"
|
||||
}
|
||||
$enginePath = Join-Path $InstallRoot 'lib\Install-FromManifest.ps1'
|
||||
if (-not (Test-Path $enginePath)) {
|
||||
Write-Warning "engine not present at $enginePath - provide -EngineSource or place it there before enforcement runs."
|
||||
}
|
||||
|
||||
# --- 5. Scheduled task: run the enforcement client as SYSTEM -------------
|
||||
if (-not $NoTask) {
|
||||
$runner = Join-Path $InstallRoot 'Invoke-ShopdbEnforce.ps1'
|
||||
$arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$runner`" -Scope `"$PCType`" -EnginePath `"$enginePath`""
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments
|
||||
$triggers = @(
|
||||
(New-ScheduledTaskTrigger -AtLogOn),
|
||||
(New-ScheduledTaskTrigger -Once -At (Get-Date) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes))
|
||||
)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers `
|
||||
-Principal $principal -Settings $settings -Force | Out-Null
|
||||
Write-Host " registered task '$TaskName' (at logon + every $IntervalMinutes min)"
|
||||
}
|
||||
|
||||
Write-Host "GE-Enforce provisioning complete."
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error "GE-Enforce provisioning FAILED: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
103
plugins/geenforce/client/Invoke-ShopdbEnforce.ps1
Normal file
103
plugins/geenforce/client/Invoke-ShopdbEnforce.ps1
Normal file
@@ -0,0 +1,103 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reference orchestrator: source a GE-Enforce manifest from shopdb, run the
|
||||
UNCHANGED engine against it, and report the result back to shopdb.
|
||||
|
||||
This is a thin wrapper around Install-FromManifest.ps1 (the engine). It is a
|
||||
reference a site adapts into its GE-Enforce.ps1 flow; it is not the live
|
||||
dispatcher. The engine, detection, self-heal, and SMB payload resolution are
|
||||
untouched - only the source of the manifest JSON moves from a share file to
|
||||
shopdb, plus a result report.
|
||||
|
||||
.PARAMETER Scope
|
||||
The imaging pc-type / scope name (e.g. gea-shopfloor-cmm), same value the
|
||||
dispatcher already resolves from C:\Enrollment\pc-type.txt.
|
||||
|
||||
.PARAMETER EnginePath
|
||||
Path to Install-FromManifest.ps1 (the engine lib, >= 2.6).
|
||||
|
||||
.PARAMETER ShareManifestPath
|
||||
The current on-share manifest for this scope. In shadow mode the engine runs
|
||||
against THIS (unchanged behavior) and shopdb is only compared + reported. Once
|
||||
cut over, omit it and the engine runs against the shopdb-sourced manifest.
|
||||
|
||||
.PARAMETER ShadowMode
|
||||
Fetch + compare + report, but install from the share (no behavior change).
|
||||
|
||||
.NOTES
|
||||
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
|
||||
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Scope,
|
||||
[Parameter(Mandatory)] [string]$EnginePath,
|
||||
[string]$ShareManifestPath,
|
||||
[switch]$ShadowMode,
|
||||
[string]$BaseUrl,
|
||||
[string]$ApiToken,
|
||||
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
|
||||
)
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message, [string]$Level = 'INFO')
|
||||
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
|
||||
try { Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue } catch {}
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
try {
|
||||
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
|
||||
|
||||
$config = Get-ShopdbConfig -BaseUrl $BaseUrl -ApiToken $ApiToken
|
||||
if (-not $config) {
|
||||
Write-Log 'No shopdb BaseUrl/ApiToken configured yet - retry next cycle.' 'WARN'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
|
||||
if (-not $sync.Path) {
|
||||
Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN'
|
||||
exit 0
|
||||
}
|
||||
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
|
||||
|
||||
# Shadow mode: compare shopdb vs the share, but install from the share.
|
||||
if ($ShadowMode -and $ShareManifestPath -and (Test-Path $ShareManifestPath)) {
|
||||
$diff = Compare-ShopdbShadow -ShopdbManifestPath $sync.Path -ShareManifestPath $ShareManifestPath
|
||||
if ($diff.Same) {
|
||||
Write-Log 'Shadow: shopdb manifest matches the share.'
|
||||
} else {
|
||||
Write-Log ("Shadow DIFF: shopdb-only=[{0}] share-only=[{1}] orderDiff={2}" -f `
|
||||
($diff.ShopdbOnly -join ','), ($diff.ShareOnly -join ','), $diff.OrderDiff) 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
# Which manifest the engine actually runs against.
|
||||
$manifestToRun = if ($ShadowMode -and $ShareManifestPath) { $ShareManifestPath } else { $sync.Path }
|
||||
|
||||
# --- INTEGRATION POINT ---------------------------------------------------
|
||||
# Run the engine. Install-FromManifest.ps1 is expected to return (or you
|
||||
# adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered
|
||||
# and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this
|
||||
# to your engine's actual return/parse; the shape below is the contract.
|
||||
Write-Log "Running engine against $manifestToRun"
|
||||
$summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||
if (-not $summary) {
|
||||
$summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() }
|
||||
}
|
||||
if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' }
|
||||
|
||||
# Report the result (best-effort).
|
||||
$appliedVersion = 0
|
||||
if ($sync.Version) { [int]::TryParse($sync.Version, [ref]$appliedVersion) | Out-Null }
|
||||
$report = New-ShopdbReport -Scope $Scope -AppliedVersion $appliedVersion -Summary $summary
|
||||
if (Send-ShopdbReport -Config $config -Report $report) {
|
||||
Write-Log "Reported: installed=$($summary.Installed) skipped=$($summary.Skipped) failed=$($summary.Failed)."
|
||||
} else {
|
||||
Write-Log 'Report POST failed (non-fatal).' 'WARN'
|
||||
}
|
||||
} catch {
|
||||
Write-Log "Unhandled error (non-fatal): $($_.Exception.Message)" 'ERROR'
|
||||
}
|
||||
exit 0
|
||||
164
plugins/geenforce/client/ShopdbEnforceClient.psm1
Normal file
164
plugins/geenforce/client/ShopdbEnforceClient.psm1
Normal file
@@ -0,0 +1,164 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Client-side helpers for sourcing GE-Enforce manifests from shopdb and reporting
|
||||
results back. Site-neutral reference kit - deploy alongside GE-Enforce; do NOT
|
||||
hardcode any site here.
|
||||
|
||||
This module does NOT replace Install-FromManifest.ps1 (the engine). It only
|
||||
changes where the manifest JSON comes from (shopdb HTTP instead of a share
|
||||
file) and adds a result report. The engine, detection, self-heal, and SMB
|
||||
payload resolution stay exactly as they are.
|
||||
|
||||
Resilience mirrors GE-Enforce: any failure is non-fatal (fail-safe). If shopdb
|
||||
is unreachable the client enforces from the last-known-good cached manifest and
|
||||
a PC is never left unmanaged because the web app is down.
|
||||
|
||||
Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB
|
||||
BaseUrl e.g. https://shopdb.example.geaerospace.net
|
||||
ApiToken a geenforce.fetch (+ geenforce.report) managed service token,
|
||||
provisioned the same way as SFLD creds (Azure DSC).
|
||||
#>
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Get-ShopdbConfig {
|
||||
param([string]$BaseUrl, [string]$ApiToken)
|
||||
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) {
|
||||
$props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue
|
||||
if (-not $BaseUrl -and $props.BaseUrl) { $BaseUrl = $props.BaseUrl }
|
||||
if (-not $ApiToken -and $props.ApiToken) { $ApiToken = $props.ApiToken }
|
||||
}
|
||||
if (-not $BaseUrl -or -not $ApiToken) { return $null }
|
||||
return @{ BaseUrl = $BaseUrl.TrimEnd('/'); ApiToken = $ApiToken }
|
||||
}
|
||||
|
||||
function Sync-ShopdbManifest {
|
||||
<#
|
||||
Fetch the current published manifest for a scope into a local cache, using
|
||||
an ETag so an unchanged manifest is a cheap 304. On any network error, fall
|
||||
back to the last-known-good cached copy. Returns:
|
||||
@{ Path; Version; Source } where Source is
|
||||
'shopdb' | 'cache-304' | 'cache-lastgood' | $null (nothing available)
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Scope,
|
||||
[Parameter(Mandatory)] [hashtable]$Config,
|
||||
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
||||
)
|
||||
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
|
||||
$manifestPath = Join-Path $CacheDir "$Scope.json"
|
||||
$etagPath = Join-Path $CacheDir "$Scope.etag"
|
||||
$headers = @{ 'X-API-Key' = $Config.ApiToken }
|
||||
if (Test-Path $etagPath) { $headers['If-None-Match'] = (Get-Content -LiteralPath $etagPath -Raw).Trim() }
|
||||
|
||||
$uri = "$($Config.BaseUrl)/api/geenforce/manifest?pctype=$([uri]::EscapeDataString($Scope))"
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing `
|
||||
-TimeoutSec 30 -ErrorAction Stop
|
||||
if ($response.StatusCode -eq 200) {
|
||||
[System.IO.File]::WriteAllText($manifestPath, $response.Content)
|
||||
# PowerShell 7 returns header values as string arrays; 5.1 as scalars.
|
||||
# @(...)[0] yields a clean scalar in both.
|
||||
$etag = @($response.Headers['ETag'])[0]
|
||||
if ($etag) {
|
||||
Set-Content -LiteralPath $etagPath -Value $etag -NoNewline
|
||||
}
|
||||
$version = @($response.Headers['X-Manifest-Version'])[0]
|
||||
if ($version) {
|
||||
Set-Content -LiteralPath (Join-Path $CacheDir "$Scope.version") -Value $version -NoNewline
|
||||
}
|
||||
return @{ Path = $manifestPath; Version = $version; Source = 'shopdb' }
|
||||
}
|
||||
} catch {
|
||||
$status = $null
|
||||
if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
|
||||
if ($status -eq 304 -and (Test-Path $manifestPath)) {
|
||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
|
||||
}
|
||||
# Network/other failure: fall back to last-known-good.
|
||||
if (Test-Path $manifestPath) {
|
||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
|
||||
}
|
||||
return @{ Path = $null; Version = $null; Source = $null }
|
||||
}
|
||||
# 304 without exception (some PS versions) -> use cache.
|
||||
if (Test-Path $manifestPath) {
|
||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
|
||||
}
|
||||
return @{ Path = $null; Version = $null; Source = $null }
|
||||
}
|
||||
|
||||
function Read-CachedVersion {
|
||||
param([string]$CacheDir, [string]$Scope)
|
||||
$verPath = Join-Path $CacheDir "$Scope.version"
|
||||
if (Test-Path $verPath) { return (Get-Content -LiteralPath $verPath -Raw).Trim() }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Compare-ShopdbShadow {
|
||||
<#
|
||||
Shadow-mode comparison: do the shopdb manifest and the on-share manifest
|
||||
select the same ordered entry names? Returns @{ Same; ShopdbOnly; ShareOnly;
|
||||
OrderDiff }. Behavioral, not byte, comparison.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$ShopdbManifestPath,
|
||||
[Parameter(Mandatory)][string]$ShareManifestPath)
|
||||
$shopdb = (Get-Content -LiteralPath $ShopdbManifestPath -Raw | ConvertFrom-Json)
|
||||
$share = (Get-Content -LiteralPath $ShareManifestPath -Raw | ConvertFrom-Json)
|
||||
$shopdbNames = @($shopdb.Applications | ForEach-Object { $_.Name })
|
||||
$shareNames = @($share.Applications | ForEach-Object { $_.Name })
|
||||
return @{
|
||||
Same = (($shopdbNames -join '|') -eq ($shareNames -join '|'))
|
||||
ShopdbOnly = @($shopdbNames | Where-Object { $_ -notin $shareNames })
|
||||
ShareOnly = @($shareNames | Where-Object { $_ -notin $shopdbNames })
|
||||
OrderDiff = (($shopdbNames -join '|') -ne ($shareNames -join '|'))
|
||||
}
|
||||
}
|
||||
|
||||
function Send-ShopdbReport {
|
||||
<#
|
||||
POST an enforcement report to shopdb. Best-effort: never throws, returns
|
||||
$true on success. Report is a hashtable matching POST /api/geenforce/report.
|
||||
#>
|
||||
param([Parameter(Mandatory)][hashtable]$Config,
|
||||
[Parameter(Mandatory)][hashtable]$Report)
|
||||
try {
|
||||
$body = ($Report | ConvertTo-Json -Depth 6)
|
||||
Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
|
||||
-Method Post -Headers @{ 'X-API-Key' = $Config.ApiToken } `
|
||||
-ContentType 'application/json' -Body $body -TimeoutSec 30 -ErrorAction Stop | Out-Null
|
||||
return $true
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function New-ShopdbReport {
|
||||
<#
|
||||
Build a report payload from an engine summary. `Summary` is expected to
|
||||
carry Installed/Skipped/Failed/Filtered counts and a Results list of
|
||||
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
|
||||
per-entry outcomes into this at the call site.
|
||||
#>
|
||||
param([string]$Hostname = $env:COMPUTERNAME,
|
||||
[Parameter(Mandatory)][string]$Scope,
|
||||
[int]$AppliedVersion,
|
||||
[Parameter(Mandatory)][hashtable]$Summary)
|
||||
return @{
|
||||
hostname = $Hostname
|
||||
scopename = $Scope
|
||||
appliedversion = $AppliedVersion
|
||||
enforcerversion = $Summary.EnforcerVersion
|
||||
counts = @{
|
||||
installed = [int]$Summary.Installed
|
||||
skipped = [int]$Summary.Skipped
|
||||
failed = [int]$Summary.Failed
|
||||
filtered = [int]$Summary.Filtered
|
||||
}
|
||||
results = @($Summary.Results)
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
|
||||
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion
|
||||
144
plugins/geenforce/filters.py
Normal file
144
plugins/geenforce/filters.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Python mirror of the GE-Enforce engine's targeting filters.
|
||||
|
||||
These re-implement, field for field, the four filter functions in
|
||||
`Install-FromManifest.ps1` (Test-PCTypeMatches, Test-HostnameMatches,
|
||||
Test-MachineNumberMatches, Test-CmmVersionMatches) plus the PCTypes alias graph
|
||||
(lines 463-475). They operate on plain manifest-entry dicts (the exact shape of
|
||||
an Applications[] entry) and a machine-profile dict, so the SAME code answers
|
||||
two questions:
|
||||
|
||||
1. Parity harness: do the on-share manifest and the shopdb-rebuilt manifest
|
||||
select the same entries in the same order for a set of machine profiles?
|
||||
2. Simulator: "what would PC Y get" - which entries apply and why.
|
||||
|
||||
The engine lib stays the single source of truth; this is a mirror. If the lib's
|
||||
alias map changes, ALIAS_GROUPS here must change with it (the parity harness's
|
||||
legacy-name profiles fail loudly on divergence).
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
|
||||
# Mirror of $script:_pcTypeAliasGroups in Install-FromManifest.ps1:463-475.
|
||||
# Rows are alias sets: names in the same set all match the same identity.
|
||||
ALIAS_GROUPS = [
|
||||
('Standard', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections',
|
||||
'gea-shopfloor-common'),
|
||||
('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'),
|
||||
('Standard-Timeclock', 'gea-shopfloor-common'),
|
||||
('CMM', 'gea-shopfloor-cmm'),
|
||||
('Keyence', 'gea-shopfloor-keyence'),
|
||||
('Lab', 'gea-shopfloor-common'),
|
||||
('WaxAndTrace', 'gea-shopfloor-waxtrace'),
|
||||
('Genspect', 'gea-shopfloor-genspect'),
|
||||
('Display', 'gea-shopfloor-display'),
|
||||
('Heattreat', 'gea-shopfloor-heattreat'),
|
||||
('PartMarker', 'gea-shopfloor-partmarker'),
|
||||
]
|
||||
|
||||
|
||||
def _alias_sets(name):
|
||||
"""Every alias group containing `name` (case-insensitive)."""
|
||||
lname = (name or '').lower()
|
||||
return [g for g in ALIAS_GROUPS if any(n.lower() == lname for n in g)]
|
||||
|
||||
|
||||
def matches_pctype(entry, pctype, subtype=None, strict_allowed=False):
|
||||
"""Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection.
|
||||
|
||||
PCTypesStrict=true disables alias expansion (both the PC's identity and the
|
||||
manifest values are compared literally), so a 'collections'-only strict entry
|
||||
does NOT match a 'nocollections' PC via their shared 'Standard' alias group.
|
||||
IMPORTANT: only the PREINSTALL runner honors this flag - the runtime engine
|
||||
(Install-FromManifest.ps1's Test-PCTypeMatches) has no strict handling. So
|
||||
strict is applied only when strict_allowed (i.e. phase == 'preinstall'),
|
||||
else a runtime entry with PCTypesStrict still alias-expands, matching the
|
||||
real engine.
|
||||
"""
|
||||
values = entry.get('PCTypes') or []
|
||||
if not values:
|
||||
return True
|
||||
if not pctype:
|
||||
return True
|
||||
strict = strict_allowed and bool(entry.get('PCTypesStrict'))
|
||||
# Names the current PC matches: bare type, "type-subtype", and (unless strict)
|
||||
# all aliases of either.
|
||||
mynames = set()
|
||||
mynames.add(pctype.lower())
|
||||
seeds = [pctype]
|
||||
if subtype:
|
||||
combined = f'{pctype}-{subtype}'
|
||||
mynames.add(combined.lower())
|
||||
seeds.append(combined)
|
||||
if not strict:
|
||||
for seed in seeds:
|
||||
for group in _alias_sets(seed):
|
||||
for alias in group:
|
||||
mynames.add(alias.lower())
|
||||
for value in values:
|
||||
if value == '*':
|
||||
return True
|
||||
if value.lower() in mynames:
|
||||
return True
|
||||
if strict:
|
||||
continue
|
||||
# The manifest value may itself be an alias - expand and check overlap.
|
||||
for group in _alias_sets(value):
|
||||
for alias in group:
|
||||
if alias.lower() in mynames:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def matches_hostname(entry, hostname):
|
||||
"""Test-HostnameMatches: exact or PowerShell -like glob (*, ?, [set])."""
|
||||
patterns = entry.get('TargetHostnames') or []
|
||||
if not patterns:
|
||||
return True
|
||||
myname = (hostname or '').lower()
|
||||
for pattern in patterns:
|
||||
lpattern = (pattern or '').lower()
|
||||
if lpattern == myname:
|
||||
return True
|
||||
if fnmatch.fnmatch(myname, lpattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def matches_machinenumber(entry, machinenumber):
|
||||
"""Test-MachineNumberMatches: filter present but PC has no number = exclude."""
|
||||
numbers = entry.get('TargetMachineNumbers') or []
|
||||
if not numbers:
|
||||
return True
|
||||
if not machinenumber:
|
||||
return False
|
||||
mynumber = str(machinenumber).lower()
|
||||
return any(str(n).lower() == mynumber for n in numbers)
|
||||
|
||||
|
||||
def matches_cmmversion(entry, cmmversion):
|
||||
"""Test-CmmVersionMatches: untagged always applies; no PC version = install-all."""
|
||||
tag = entry.get('_CmmVersion')
|
||||
if not tag:
|
||||
return True
|
||||
if not cmmversion:
|
||||
return True
|
||||
return str(tag).lower() == str(cmmversion).lower()
|
||||
|
||||
|
||||
def entry_applies(entry, profile):
|
||||
"""All four filters ANDed, exactly as the engine's main loop applies them.
|
||||
|
||||
`profile` keys: pctype, subtype, hostname, machinenumber, cmmversion, and
|
||||
optional phase ('preinstall' enables PCTypesStrict, matching the runners).
|
||||
"""
|
||||
strict_allowed = profile.get('phase') == 'preinstall'
|
||||
return (matches_pctype(entry, profile.get('pctype'), profile.get('subtype'),
|
||||
strict_allowed)
|
||||
and matches_hostname(entry, profile.get('hostname'))
|
||||
and matches_machinenumber(entry, profile.get('machinenumber'))
|
||||
and matches_cmmversion(entry, profile.get('cmmversion')))
|
||||
|
||||
|
||||
def applicable_entry_names(applications, profile):
|
||||
"""Ordered list of entry Names that pass all filters for a profile."""
|
||||
return [e.get('Name') for e in applications if entry_applies(e, profile)]
|
||||
117
plugins/geenforce/importer.py
Normal file
117
plugins/geenforce/importer.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Parse on-share manifest.json / preinstall.json into draft DB rows.
|
||||
|
||||
The reverse of serializer.py. `build_scope` turns a parsed manifest dict into a
|
||||
ManifestScope with its ordered entries and child rows (not yet committed).
|
||||
`discover_share` walks a GE-Enforce share root and yields (scopename, phase,
|
||||
manifest_dict) for common + every gea-shopfloor-* runtime manifest, skipping
|
||||
`.bak` variants. `load_preinstall` reads a preinstall.json.
|
||||
|
||||
Import is a rebuild: the caller replaces a scope's draft rows so re-running is
|
||||
idempotent.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from .models import (
|
||||
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
|
||||
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||
)
|
||||
from .serializer import _SCALAR_FIELDS, _BOOL_FLAGS
|
||||
|
||||
# manifest key -> model attr (reverse of the serializer's scalar map).
|
||||
_KEY_TO_ATTR = {mk: attr for attr, mk in _SCALAR_FIELDS}
|
||||
_FLAG_TO_ATTR = {mk: attr for attr, mk in _BOOL_FLAGS}
|
||||
|
||||
|
||||
# Scalar model attributes that a populate resets (so an update clears fields the
|
||||
# new payload omits). name/entrytype are required and set explicitly.
|
||||
_RESETTABLE_ATTRS = [attr for _, attr in _SCALAR_FIELDS
|
||||
if attr not in ('name', 'entrytype')] + ['comment', 'regvalue']
|
||||
|
||||
|
||||
def populate_entry(entry, entry_dict):
|
||||
"""Set every field + child on an existing (or new) ManifestEntry from one
|
||||
Applications[] entry dict. Existing children are replaced. Returns entry."""
|
||||
entry.name = entry_dict.get('Name')
|
||||
entry.entrytype = entry_dict.get('Type')
|
||||
entry.comment = entry_dict.get('_comment')
|
||||
for attr in _RESETTABLE_ATTRS:
|
||||
if attr in ('comment', 'regvalue'):
|
||||
continue
|
||||
setattr(entry, attr, None)
|
||||
for key, attr in _KEY_TO_ATTR.items():
|
||||
if attr in ('name', 'entrytype'):
|
||||
continue
|
||||
if key in entry_dict and entry_dict[key] is not None:
|
||||
setattr(entry, attr, entry_dict[key])
|
||||
# RegValue stored as its raw JSON literal so DWord vs string typing survives.
|
||||
entry.regvalue = (json.dumps(entry_dict['RegValue'])
|
||||
if 'RegValue' in entry_dict else None)
|
||||
for key, attr in _FLAG_TO_ATTR.items():
|
||||
setattr(entry, attr, bool(entry_dict.get(key)))
|
||||
# Multi-value filters -> child rows (replace, preserve order).
|
||||
entry.pctypes = [ManifestEntryPcType(sortorder=i, pctypevalue=value)
|
||||
for i, value in enumerate(entry_dict.get('PCTypes') or [])]
|
||||
entry.hostnames = [ManifestEntryHostname(sortorder=i, hostnamepattern=value)
|
||||
for i, value in enumerate(entry_dict.get('TargetHostnames') or [])]
|
||||
entry.machinenumbers = [
|
||||
ManifestEntryMachineNumber(sortorder=i, machinenumber=str(value))
|
||||
for i, value in enumerate(entry_dict.get('TargetMachineNumbers') or [])]
|
||||
# InUseCheck object + Processes[].
|
||||
inuse = entry_dict.get('InUseCheck')
|
||||
if inuse:
|
||||
check = ManifestInUseCheck(behavior=inuse.get('Behavior'))
|
||||
for i, proc in enumerate(inuse.get('Processes') or []):
|
||||
check.processes.append(ManifestInUseCheckProcess(
|
||||
sortorder=i,
|
||||
processname=proc.get('Name'),
|
||||
exepath=proc.get('ExePath'),
|
||||
gracefulclosetimeoutsec=proc.get('GracefulCloseTimeoutSec')))
|
||||
entry.inusecheck = check
|
||||
else:
|
||||
entry.inusecheck = None
|
||||
return entry
|
||||
|
||||
|
||||
def build_entry(entry_dict, sortorder):
|
||||
"""Build a new ManifestEntry (+ children) from one Applications[] entry."""
|
||||
entry = ManifestEntry(sortorder=sortorder)
|
||||
return populate_entry(entry, entry_dict)
|
||||
|
||||
|
||||
def build_scope(scopename, phase, manifest_dict, iscommon=False):
|
||||
"""Build a ManifestScope (+ entries) from a parsed manifest dict."""
|
||||
scope = ManifestScope(
|
||||
scopename=scopename,
|
||||
phase=phase,
|
||||
manifestversion=str(manifest_dict.get('Version', '1.0')),
|
||||
topcomment=manifest_dict.get('_comment'),
|
||||
site=manifest_dict.get('Site'),
|
||||
iscommon=iscommon)
|
||||
for i, entry_dict in enumerate(manifest_dict.get('Applications') or []):
|
||||
scope.entries.append(build_entry(entry_dict, i))
|
||||
return scope
|
||||
|
||||
|
||||
def discover_share(shareroot):
|
||||
"""Yield (scopename, phase, manifest_dict) for runtime manifests on a share.
|
||||
|
||||
Reads `common/manifest.json` and every `gea-shopfloor-*/manifest.json`.
|
||||
Skips `.bak` / `.pre-*.bak` variants (only the exact `manifest.json`).
|
||||
"""
|
||||
for name in sorted(os.listdir(shareroot)):
|
||||
if name != 'common' and not name.startswith('gea-shopfloor-'):
|
||||
continue
|
||||
path = os.path.join(shareroot, name, 'manifest.json')
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
with open(path) as handle:
|
||||
manifest = json.load(handle)
|
||||
yield name, 'runtime', manifest
|
||||
|
||||
|
||||
def load_manifest_file(path):
|
||||
"""Parse a single manifest.json / preinstall.json file."""
|
||||
with open(path) as handle:
|
||||
return json.load(handle)
|
||||
14
plugins/geenforce/manifest.json
Normal file
14
plugins/geenforce/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "geenforce",
|
||||
"display_name": "GE-Enforce",
|
||||
"version": "0.1.0",
|
||||
"description": "GE-Enforce manifest store. Owns imaging PC-type scopes and their install manifests (apps, scripts, files, registry, version gates) as shopdb data, served to the GE-Enforce client as JSON. Requires GE-Enforce lib >= 2.6 on target PCs (the _CmmVersion gate).",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.7.0,<1.0.0",
|
||||
"api_prefix": "/api/geenforce",
|
||||
"default_enabled": false,
|
||||
"provides": {
|
||||
"features": ["manifest-store", "imaging-pc-types"]
|
||||
}
|
||||
}
|
||||
14
plugins/geenforce/migrations/env.py
Normal file
14
plugins/geenforce/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the GE-Enforce plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
|
||||
the metadata to this plugin's tables and drives Alembic against the per-plugin
|
||||
version table alembic_version_geenforce (ADR-008). This plugin is NEW: its 0001
|
||||
baseline really CREATES its tables, because the core chain never built them.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'geenforce'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/geenforce/migrations/script.py.mako
Normal file
24
plugins/geenforce/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
241
plugins/geenforce/migrations/versions/0001_geenforce_baseline.py
Normal file
241
plugins/geenforce/migrations/versions/0001_geenforce_baseline.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""geenforce plugin baseline (real create).
|
||||
|
||||
This plugin was built after the ADR-008 ownership cutover, so this baseline
|
||||
actually CREATES the plugin's tables (the core chain never knew about them). It
|
||||
runs from `flask plugin install geenforce` / `flask plugin upgrade-all` after
|
||||
`flask db upgrade` builds the core schema. All foreign keys are intra-plugin, so
|
||||
tables are created parent-first. computertypeid / measuringtooltypeid /
|
||||
publishedby are SOFT references (plain columns, no FK) to optional plugins/core.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'geenforce0001baseline'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'manifestscopes',
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopename', sa.String(length=64), nullable=False),
|
||||
sa.Column('phase', sa.String(length=16), nullable=False),
|
||||
sa.Column('computertypeid', sa.Integer(), nullable=True),
|
||||
sa.Column('measuringtooltypeid', sa.Integer(), nullable=True),
|
||||
sa.Column('manifestversion', sa.String(length=16), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('topcomment', sa.Text(), nullable=True),
|
||||
sa.Column('site', sa.String(length=100), nullable=True),
|
||||
sa.Column('iscommon', sa.Boolean(), nullable=False),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('scopeid'),
|
||||
sa.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
|
||||
)
|
||||
op.create_table(
|
||||
'manifestentries',
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('entrytype', sa.String(length=16), nullable=False),
|
||||
sa.Column('comment', sa.Text(), nullable=True),
|
||||
sa.Column('installer', sa.String(length=255), nullable=True),
|
||||
sa.Column('installargs', sa.Text(), nullable=True),
|
||||
sa.Column('scriptpath', sa.String(length=255), nullable=True),
|
||||
sa.Column('scriptargs', sa.String(length=255), nullable=True),
|
||||
sa.Column('sourcepath', sa.String(length=255), nullable=True),
|
||||
sa.Column('destination', sa.String(length=255), nullable=True),
|
||||
sa.Column('regpath', sa.String(length=255), nullable=True),
|
||||
sa.Column('regname', sa.String(length=128), nullable=True),
|
||||
sa.Column('regvalue', sa.Text(), nullable=True),
|
||||
sa.Column('regtype', sa.String(length=16), nullable=True),
|
||||
sa.Column('payloadsource', sa.String(length=8), nullable=False),
|
||||
sa.Column('payloadref', sa.String(length=512), nullable=True),
|
||||
sa.Column('payloadsha256', sa.String(length=64), nullable=True),
|
||||
sa.Column('detectionmethod', sa.String(length=16), nullable=True),
|
||||
sa.Column('detectionpath', sa.String(length=255), nullable=True),
|
||||
sa.Column('detectionname', sa.String(length=128), nullable=True),
|
||||
sa.Column('detectionvalue', sa.String(length=255), nullable=True),
|
||||
sa.Column('detectionpattern', sa.String(length=255), nullable=True),
|
||||
sa.Column('cmmversion', sa.String(length=16), nullable=True),
|
||||
sa.Column('logfile', sa.String(length=255), nullable=True),
|
||||
sa.Column('waittimeoutsec', sa.Integer(), nullable=True),
|
||||
sa.Column('applymode', sa.String(length=16), nullable=True),
|
||||
sa.Column('updatewindow', sa.String(length=11), nullable=True),
|
||||
sa.Column('appid', sa.Integer(), nullable=True),
|
||||
sa.Column('preenrollment', sa.Boolean(), nullable=False),
|
||||
sa.Column('killafterdetection', sa.Boolean(), nullable=False),
|
||||
sa.Column('pctypesstrict', sa.Boolean(), nullable=False),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entryid'),
|
||||
sa.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
|
||||
)
|
||||
op.create_index('idx_entry_scope', 'manifestentries', ['scopeid'])
|
||||
op.create_index('idx_entry_scope_order', 'manifestentries',
|
||||
['scopeid', 'sortorder'])
|
||||
op.create_table(
|
||||
'manifestentrypctypes',
|
||||
sa.Column('entrypctypeid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False),
|
||||
sa.Column('pctypevalue', sa.String(length=64), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entrypctypeid'),
|
||||
)
|
||||
op.create_index('idx_pctype_entry', 'manifestentrypctypes', ['entryid'])
|
||||
op.create_table(
|
||||
'manifestentryhostnames',
|
||||
sa.Column('entryhostnameid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False),
|
||||
sa.Column('hostnamepattern', sa.String(length=64), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entryhostnameid'),
|
||||
)
|
||||
op.create_index('idx_hostname_entry', 'manifestentryhostnames', ['entryid'])
|
||||
op.create_table(
|
||||
'manifestentrymachinenumbers',
|
||||
sa.Column('entrymachinenumberid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False),
|
||||
sa.Column('machinenumber', sa.String(length=16), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entrymachinenumberid'),
|
||||
)
|
||||
op.create_index('idx_machinenumber_entry', 'manifestentrymachinenumbers',
|
||||
['entryid'])
|
||||
op.create_table(
|
||||
'manifestinusechecks',
|
||||
sa.Column('inusecheckid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('behavior', sa.String(length=20), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('inusecheckid'),
|
||||
sa.UniqueConstraint('entryid'),
|
||||
)
|
||||
op.create_table(
|
||||
'manifestinusecheckprocesses',
|
||||
sa.Column('inusecheckprocessid', sa.Integer(), nullable=False),
|
||||
sa.Column('inusecheckid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False),
|
||||
sa.Column('processname', sa.String(length=64), nullable=False),
|
||||
sa.Column('exepath', sa.String(length=255), nullable=True),
|
||||
sa.Column('gracefulclosetimeoutsec', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['inusecheckid'],
|
||||
['manifestinusechecks.inusecheckid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('inusecheckprocessid'),
|
||||
)
|
||||
op.create_index('idx_process_check', 'manifestinusecheckprocesses',
|
||||
['inusecheckid'])
|
||||
op.create_table(
|
||||
'manifestpublishedversions',
|
||||
sa.Column('publishedversionid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
sa.Column('versionnumber', sa.Integer(), nullable=False),
|
||||
sa.Column('manifestjson', sa.Text(length=16777215), nullable=False),
|
||||
sa.Column('publishedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('publishedby', sa.Integer(), nullable=True),
|
||||
sa.Column('iscurrent', sa.Boolean(), nullable=False),
|
||||
sa.Column('notes', sa.String(length=255), nullable=True),
|
||||
sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('publishedversionid'),
|
||||
sa.UniqueConstraint('scopeid', 'versionnumber',
|
||||
name='uq_published_scope_version'),
|
||||
)
|
||||
op.create_index('idx_published_scope', 'manifestpublishedversions',
|
||||
['scopeid'])
|
||||
op.create_table(
|
||||
'manifestpayloads',
|
||||
sa.Column('payloadid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('filename', sa.String(length=255), nullable=False),
|
||||
sa.Column('contenttype', sa.String(length=128), nullable=True),
|
||||
sa.Column('payloadbytes', sa.LargeBinary(length=16777215), nullable=False),
|
||||
sa.Column('payloadsha256', sa.String(length=64), nullable=False),
|
||||
sa.Column('uploadedat', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('payloadid'),
|
||||
)
|
||||
op.create_index('idx_payload_entry', 'manifestpayloads', ['entryid'])
|
||||
op.create_table(
|
||||
'manifestenforcementreports',
|
||||
sa.Column('reportid', sa.Integer(), nullable=False),
|
||||
sa.Column('hostname', sa.String(length=100), nullable=False),
|
||||
sa.Column('scopename', sa.String(length=64), nullable=False),
|
||||
sa.Column('phase', sa.String(length=16), nullable=False),
|
||||
sa.Column('appliedversion', sa.Integer(), nullable=True),
|
||||
sa.Column('enforcerversion', sa.String(length=20), nullable=True),
|
||||
sa.Column('installedcount', sa.Integer(), nullable=False),
|
||||
sa.Column('skippedcount', sa.Integer(), nullable=False),
|
||||
sa.Column('failedcount', sa.Integer(), nullable=False),
|
||||
sa.Column('filteredcount', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('lastcheckin', sa.DateTime(), nullable=True),
|
||||
sa.Column('receivedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('iscurrent', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('reportid'),
|
||||
)
|
||||
op.create_index('idx_report_host', 'manifestenforcementreports',
|
||||
['hostname'])
|
||||
op.create_index('idx_report_current', 'manifestenforcementreports',
|
||||
['iscurrent'])
|
||||
op.create_index('idx_report_host_scope', 'manifestenforcementreports',
|
||||
['hostname', 'scopename', 'phase'])
|
||||
op.create_table(
|
||||
'manifestenforcementresults',
|
||||
sa.Column('resultid', sa.Integer(), nullable=False),
|
||||
sa.Column('reportid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryname', sa.String(length=128), nullable=False),
|
||||
sa.Column('action', sa.String(length=16), nullable=False),
|
||||
sa.Column('selfhealed', sa.Boolean(), nullable=False),
|
||||
sa.Column('exitcode', sa.Integer(), nullable=True),
|
||||
sa.Column('message', sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['reportid'],
|
||||
['manifestenforcementreports.reportid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('resultid'),
|
||||
)
|
||||
op.create_index('idx_result_report', 'manifestenforcementresults',
|
||||
['reportid'])
|
||||
op.create_table(
|
||||
'pctypealiases',
|
||||
sa.Column('aliasid', sa.Integer(), nullable=False),
|
||||
sa.Column('aliasgroup', sa.Integer(), nullable=False),
|
||||
sa.Column('aliasname', sa.String(length=64), nullable=False),
|
||||
sa.PrimaryKeyConstraint('aliasid'),
|
||||
sa.UniqueConstraint('aliasgroup', 'aliasname',
|
||||
name='uq_alias_group_name'),
|
||||
)
|
||||
op.create_index('idx_alias_group', 'pctypealiases', ['aliasgroup'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('pctypealiases')
|
||||
op.drop_table('manifestenforcementresults')
|
||||
op.drop_table('manifestenforcementreports')
|
||||
op.drop_table('manifestpayloads')
|
||||
op.drop_table('manifestpublishedversions')
|
||||
op.drop_table('manifestinusecheckprocesses')
|
||||
op.drop_table('manifestinusechecks')
|
||||
op.drop_table('manifestentrymachinenumbers')
|
||||
op.drop_table('manifestentryhostnames')
|
||||
op.drop_table('manifestentrypctypes')
|
||||
op.drop_table('manifestentries')
|
||||
op.drop_table('manifestscopes')
|
||||
45
plugins/geenforce/models/__init__.py
Normal file
45
plugins/geenforce/models/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""GE-Enforce plugin models."""
|
||||
|
||||
from .manifest import (
|
||||
ManifestScope,
|
||||
ManifestEntry,
|
||||
ManifestEntryPcType,
|
||||
ManifestEntryHostname,
|
||||
ManifestEntryMachineNumber,
|
||||
ManifestInUseCheck,
|
||||
ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion,
|
||||
ManifestPayload,
|
||||
ManifestEnforcementReport,
|
||||
ManifestEnforcementResult,
|
||||
PcTypeAlias,
|
||||
PHASES,
|
||||
ENTRY_TYPES,
|
||||
REG_TYPES,
|
||||
PAYLOAD_SOURCES,
|
||||
DETECTION_METHODS,
|
||||
APPLY_MODES,
|
||||
INUSE_BEHAVIORS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'ManifestScope',
|
||||
'ManifestEntry',
|
||||
'ManifestEntryPcType',
|
||||
'ManifestEntryHostname',
|
||||
'ManifestEntryMachineNumber',
|
||||
'ManifestInUseCheck',
|
||||
'ManifestInUseCheckProcess',
|
||||
'ManifestPublishedVersion',
|
||||
'ManifestPayload',
|
||||
'ManifestEnforcementReport',
|
||||
'ManifestEnforcementResult',
|
||||
'PcTypeAlias',
|
||||
'PHASES',
|
||||
'ENTRY_TYPES',
|
||||
'REG_TYPES',
|
||||
'PAYLOAD_SOURCES',
|
||||
'DETECTION_METHODS',
|
||||
'APPLY_MODES',
|
||||
'INUSE_BEHAVIORS',
|
||||
]
|
||||
353
plugins/geenforce/models/manifest.py
Normal file
353
plugins/geenforce/models/manifest.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""GE-Enforce manifest-store models.
|
||||
|
||||
The manifest that GE-Enforce runs on each PC becomes shopdb data. A
|
||||
`ManifestScope` is one imaging PC type (a `gea-shopfloor-*` scope, the `common`
|
||||
fleet-wide scope, or the single flat `preinstall` scope). Each scope owns an
|
||||
ordered list of `ManifestEntry` rows - one per Applications[] entry in the
|
||||
on-share manifest.json - plus child rows for the multi-value targeting filters
|
||||
and the nested InUseCheck.
|
||||
|
||||
Design choices (see the ge-enforce-plugin proposal in the project wiki):
|
||||
- ONE wide entries table with an `entrytype` discriminator and nullable
|
||||
per-type columns. Not STI, not a JSON blob: the whole fleet is ~64 entries,
|
||||
so sparse columns are free and every field stays queryable + plain-SQL
|
||||
readable by IT.
|
||||
- Array order IS execution order, so `sortorder` is a real column and the
|
||||
contract; config-restore entries sit after their installer on purpose.
|
||||
- `computertypeid` / `measuringtooltypeid` are SOFT references (plain Integer,
|
||||
no DB foreign key) to other plugins' tables, which may not be installed.
|
||||
- Published manifests are immutable snapshots that freeze the rendered JSON
|
||||
document (`ManifestPublishedVersion`); the draft (`ManifestEntry`) is never
|
||||
served to a client.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
# Allowed enumerations, validated in the API/service layer (stored as strings
|
||||
# for portability, matching the rest of the codebase).
|
||||
PHASES = ('runtime', 'preinstall')
|
||||
ENTRY_TYPES = ('MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry')
|
||||
REG_TYPES = ('String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary')
|
||||
PAYLOAD_SOURCES = ('smb', 'http', 'inline')
|
||||
DETECTION_METHODS = ('Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
|
||||
'ValueMatches', 'pnputil', 'Always')
|
||||
APPLY_MODES = ('Nightly', 'Immediate', 'ImmediateReboot')
|
||||
INUSE_BEHAVIORS = ('Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot')
|
||||
|
||||
|
||||
class ManifestScope(BaseModel):
|
||||
"""One imaging PC type / manifest scope (the working/draft head)."""
|
||||
__tablename__ = 'manifestscopes'
|
||||
|
||||
scopeid = db.Column(db.Integer, primary_key=True)
|
||||
# 'common', 'gea-shopfloor-cmm', 'preinstall', ...
|
||||
scopename = db.Column(db.String(64), nullable=False)
|
||||
# 'runtime' (per-pctype manifest.json) or 'preinstall' (one flat manifest)
|
||||
phase = db.Column(db.String(16), nullable=False, default='runtime')
|
||||
# Soft refs (no FK): the plugins that own these tables are optional.
|
||||
computertypeid = db.Column(db.Integer, nullable=True)
|
||||
measuringtooltypeid = db.Column(db.Integer, nullable=True)
|
||||
manifestversion = db.Column(db.String(16), nullable=False, default='1.0')
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
# The manifest-level '_comment' (documentation), preserved verbatim so
|
||||
# export-to-share round-trips it. Excluded from behavioral parity.
|
||||
topcomment = db.Column(db.Text, nullable=True)
|
||||
iscommon = db.Column(db.Boolean, nullable=False, default=False)
|
||||
# Preinstall manifests carry a top-level 'Site' field.
|
||||
site = db.Column(db.String(100), nullable=True)
|
||||
|
||||
entries = db.relationship(
|
||||
'ManifestEntry', back_populates='scope',
|
||||
cascade='all, delete-orphan', order_by='ManifestEntry.sortorder',
|
||||
lazy='selectin')
|
||||
publishedversions = db.relationship(
|
||||
'ManifestPublishedVersion', back_populates='scope',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='ManifestPublishedVersion.versionnumber', lazy='dynamic')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ManifestScope {self.scopename}/{self.phase}>"
|
||||
|
||||
|
||||
class ManifestEntry(BaseModel):
|
||||
"""One Applications[] entry (draft). Wide table, entrytype discriminator."""
|
||||
__tablename__ = 'manifestentries'
|
||||
|
||||
entryid = db.Column(db.Integer, primary_key=True)
|
||||
scopeid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
entrytype = db.Column(db.String(16), nullable=False)
|
||||
comment = db.Column(db.Text, nullable=True) # manifest '_comment'
|
||||
|
||||
# Type-specific payload references (nullable; validated per entrytype).
|
||||
installer = db.Column(db.String(255), nullable=True)
|
||||
installargs = db.Column(db.Text, nullable=True) # can run long
|
||||
scriptpath = db.Column(db.String(255), nullable=True) # 'Script'
|
||||
scriptargs = db.Column(db.String(255), nullable=True) # 'Args'
|
||||
sourcepath = db.Column(db.String(255), nullable=True) # 'Source'
|
||||
destination = db.Column(db.String(255), nullable=True)
|
||||
regpath = db.Column(db.String(255), nullable=True)
|
||||
regname = db.Column(db.String(128), nullable=True)
|
||||
# Raw JSON literal preserved verbatim (1 vs "1"); DWord typing depends on it.
|
||||
regvalue = db.Column(db.Text, nullable=True)
|
||||
regtype = db.Column(db.String(16), nullable=True)
|
||||
|
||||
# Payload transport + integrity (integrity hash is SEPARATE from detection).
|
||||
payloadsource = db.Column(db.String(8), nullable=False, default='smb')
|
||||
payloadref = db.Column(db.String(512), nullable=True)
|
||||
payloadsha256 = db.Column(db.String(64), nullable=True)
|
||||
|
||||
# Detection (decides whether the action fires / self-heals).
|
||||
detectionmethod = db.Column(db.String(16), nullable=True)
|
||||
detectionpath = db.Column(db.String(255), nullable=True)
|
||||
detectionname = db.Column(db.String(128), nullable=True)
|
||||
detectionvalue = db.Column(db.String(255), nullable=True)
|
||||
detectionpattern = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Gates + control.
|
||||
cmmversion = db.Column(db.String(16), nullable=True) # '_CmmVersion'
|
||||
logfile = db.Column(db.String(255), nullable=True)
|
||||
waittimeoutsec = db.Column(db.Integer, nullable=True)
|
||||
applymode = db.Column(db.String(16), nullable=True) # inert in engine today
|
||||
updatewindow = db.Column(db.String(11), nullable=True) # 'HH:MM-HH:MM', inert
|
||||
|
||||
# Optional CURATED link to a core Applications catalog row (soft ref, no FK
|
||||
# to keep the plugin decoupled). shopdb metadata only - NOT part of the
|
||||
# manifest JSON the engine reads, so it never affects enforcement or parity.
|
||||
appid = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Preinstall-only flags.
|
||||
preenrollment = db.Column(db.Boolean, nullable=False, default=False)
|
||||
killafterdetection = db.Column(db.Boolean, nullable=False, default=False)
|
||||
pctypesstrict = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
scope = db.relationship('ManifestScope', back_populates='entries')
|
||||
pctypes = db.relationship(
|
||||
'ManifestEntryPcType', back_populates='entry',
|
||||
cascade='all, delete-orphan', order_by='ManifestEntryPcType.sortorder',
|
||||
lazy='selectin')
|
||||
hostnames = db.relationship(
|
||||
'ManifestEntryHostname', back_populates='entry',
|
||||
cascade='all, delete-orphan', order_by='ManifestEntryHostname.sortorder',
|
||||
lazy='selectin')
|
||||
machinenumbers = db.relationship(
|
||||
'ManifestEntryMachineNumber', back_populates='entry',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='ManifestEntryMachineNumber.sortorder', lazy='selectin')
|
||||
inusecheck = db.relationship(
|
||||
'ManifestInUseCheck', back_populates='entry', uselist=False,
|
||||
cascade='all, delete-orphan', lazy='selectin')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
|
||||
db.Index('idx_entry_scope_order', 'scopeid', 'sortorder'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ManifestEntry {self.name} ({self.entrytype})>"
|
||||
|
||||
|
||||
class ManifestEntryPcType(db.Model):
|
||||
"""One value of an entry's PCTypes filter (verbatim, incl. '*' and aliases)."""
|
||||
__tablename__ = 'manifestentrypctypes'
|
||||
|
||||
entrypctypeid = db.Column(db.Integer, primary_key=True)
|
||||
entryid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
pctypevalue = db.Column(db.String(64), nullable=False)
|
||||
|
||||
entry = db.relationship('ManifestEntry', back_populates='pctypes')
|
||||
|
||||
|
||||
class ManifestEntryHostname(db.Model):
|
||||
"""One value of an entry's TargetHostnames filter (wildcards kept verbatim)."""
|
||||
__tablename__ = 'manifestentryhostnames'
|
||||
|
||||
entryhostnameid = db.Column(db.Integer, primary_key=True)
|
||||
entryid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
hostnamepattern = db.Column(db.String(64), nullable=False)
|
||||
|
||||
entry = db.relationship('ManifestEntry', back_populates='hostnames')
|
||||
|
||||
|
||||
class ManifestEntryMachineNumber(db.Model):
|
||||
"""One value of an entry's TargetMachineNumbers filter."""
|
||||
__tablename__ = 'manifestentrymachinenumbers'
|
||||
|
||||
entrymachinenumberid = db.Column(db.Integer, primary_key=True)
|
||||
entryid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
machinenumber = db.Column(db.String(16), nullable=False)
|
||||
|
||||
entry = db.relationship('ManifestEntry', back_populates='machinenumbers')
|
||||
|
||||
|
||||
class ManifestInUseCheck(db.Model):
|
||||
"""The InUseCheck object on an entry (0..1), with its Processes[] children."""
|
||||
__tablename__ = 'manifestinusechecks'
|
||||
|
||||
inusecheckid = db.Column(db.Integer, primary_key=True)
|
||||
entryid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
||||
nullable=False, unique=True)
|
||||
behavior = db.Column(db.String(20), nullable=False)
|
||||
|
||||
entry = db.relationship('ManifestEntry', back_populates='inusecheck')
|
||||
processes = db.relationship(
|
||||
'ManifestInUseCheckProcess', back_populates='inusecheck',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='ManifestInUseCheckProcess.sortorder', lazy='selectin')
|
||||
|
||||
|
||||
class ManifestInUseCheckProcess(db.Model):
|
||||
"""One process in an InUseCheck's Processes[] list."""
|
||||
__tablename__ = 'manifestinusecheckprocesses'
|
||||
|
||||
inusecheckprocessid = db.Column(db.Integer, primary_key=True)
|
||||
inusecheckid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('manifestinusechecks.inusecheckid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
processname = db.Column(db.String(64), nullable=False)
|
||||
exepath = db.Column(db.String(255), nullable=True)
|
||||
# Null = engine default (10); do not bake the default into the row.
|
||||
gracefulclosetimeoutsec = db.Column(db.Integer, nullable=True)
|
||||
|
||||
inusecheck = db.relationship('ManifestInUseCheck', back_populates='processes')
|
||||
|
||||
|
||||
class ManifestPublishedVersion(db.Model):
|
||||
"""Immutable published snapshot: the frozen rendered JSON document.
|
||||
|
||||
The client is always served the `iscurrent` snapshot for a scope, never the
|
||||
live draft. Rollback flips `iscurrent`. Freezing the text (not row-mirroring)
|
||||
makes immutability structural and rollback a one-flag change.
|
||||
"""
|
||||
__tablename__ = 'manifestpublishedversions'
|
||||
|
||||
publishedversionid = db.Column(db.Integer, primary_key=True)
|
||||
scopeid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
versionnumber = db.Column(db.Integer, nullable=False)
|
||||
# MEDIUMTEXT on MySQL (a full scope with comments can exceed 64 KB TEXT).
|
||||
manifestjson = db.Column(db.Text(length=16777215), nullable=False)
|
||||
publishedat = db.Column(db.DateTime, nullable=False)
|
||||
publishedby = db.Column(db.Integer, nullable=True) # soft ref to users
|
||||
iscurrent = db.Column(db.Boolean, nullable=False, default=False)
|
||||
notes = db.Column(db.String(255), nullable=True)
|
||||
|
||||
scope = db.relationship('ManifestScope', back_populates='publishedversions')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('scopeid', 'versionnumber',
|
||||
name='uq_published_scope_version'),
|
||||
)
|
||||
|
||||
|
||||
class ManifestPayload(db.Model):
|
||||
"""Inline payload bytes for payloadsource='inline' (small scripts/configs)."""
|
||||
__tablename__ = 'manifestpayloads'
|
||||
|
||||
payloadid = db.Column(db.Integer, primary_key=True)
|
||||
entryid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
contenttype = db.Column(db.String(128), nullable=True)
|
||||
payloadbytes = db.Column(db.LargeBinary(length=16777215), nullable=False)
|
||||
payloadsha256 = db.Column(db.String(64), nullable=False)
|
||||
uploadedat = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
|
||||
class ManifestEnforcementReport(db.Model):
|
||||
"""One enforcement cycle reported by a PC (observed state).
|
||||
|
||||
Each cycle the client POSTs its result for a scope: the published version it
|
||||
actually applied (so shopdb knows whether the PC RECEIVED the latest update),
|
||||
the enforcer version, and the installed/skipped/failed/filtered counts. The
|
||||
latest report per (hostname, scopename, phase) carries `iscurrent`; older
|
||||
ones are history. Pairs desired state (the manifest) with observed state.
|
||||
"""
|
||||
__tablename__ = 'manifestenforcementreports'
|
||||
|
||||
reportid = db.Column(db.Integer, primary_key=True)
|
||||
hostname = db.Column(db.String(100), nullable=False, index=True)
|
||||
scopename = db.Column(db.String(64), nullable=False)
|
||||
phase = db.Column(db.String(16), nullable=False, default='runtime')
|
||||
# Published version the client actually ran; compare to the scope's current
|
||||
# published version to see whether this PC received the latest manifest.
|
||||
appliedversion = db.Column(db.Integer, nullable=True)
|
||||
enforcerversion = db.Column(db.String(20), nullable=True)
|
||||
installedcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
skippedcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
failedcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
filteredcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
# 'ok' | 'failed' (any failure) | 'selfhealed' (drift corrected, no failure).
|
||||
status = db.Column(db.String(16), nullable=False, default='ok')
|
||||
lastcheckin = db.Column(db.DateTime, nullable=True) # PC-reported time
|
||||
receivedat = db.Column(db.DateTime, nullable=False) # server time
|
||||
iscurrent = db.Column(db.Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
results = db.relationship(
|
||||
'ManifestEnforcementResult', back_populates='report',
|
||||
cascade='all, delete-orphan', lazy='selectin')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_report_host_scope', 'hostname', 'scopename', 'phase'),
|
||||
)
|
||||
|
||||
|
||||
class ManifestEnforcementResult(db.Model):
|
||||
"""One entry's outcome within an enforcement cycle (self-heal detail)."""
|
||||
__tablename__ = 'manifestenforcementresults'
|
||||
|
||||
resultid = db.Column(db.Integer, primary_key=True)
|
||||
reportid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('manifestenforcementreports.reportid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
entryname = db.Column(db.String(128), nullable=False)
|
||||
# 'installed' (action fired - a self-heal when it should already be present),
|
||||
# 'skipped' (detected present), 'failed', 'filtered'.
|
||||
action = db.Column(db.String(16), nullable=False)
|
||||
# True when this install was a drift correction (self-heal), not a first
|
||||
# install. Client-supplied; defaults to whether the action installed.
|
||||
selfhealed = db.Column(db.Boolean, nullable=False, default=False)
|
||||
exitcode = db.Column(db.Integer, nullable=True)
|
||||
message = db.Column(db.Text, nullable=True) # warning / error text
|
||||
|
||||
report = db.relationship('ManifestEnforcementReport', back_populates='results')
|
||||
|
||||
|
||||
class PcTypeAlias(db.Model):
|
||||
"""Mirror of the engine lib's PCTypes alias graph (Install-FromManifest.ps1).
|
||||
|
||||
Rows sharing an `aliasgroup` are one alias set. Server-side resolve/validate
|
||||
only; the engine lib stays the single source of truth (never inverted).
|
||||
"""
|
||||
__tablename__ = 'pctypealiases'
|
||||
|
||||
aliasid = db.Column(db.Integer, primary_key=True)
|
||||
aliasgroup = db.Column(db.Integer, nullable=False, index=True)
|
||||
aliasname = db.Column(db.String(64), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('aliasgroup', 'aliasname', name='uq_alias_group_name'),
|
||||
)
|
||||
106
plugins/geenforce/parity.py
Normal file
106
plugins/geenforce/parity.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""P1 behavioral-parity harness (Gate A).
|
||||
|
||||
Proves the model is lossless WITHOUT byte-diffing: for each manifest, import it
|
||||
into in-memory rows and render it back out, then check that
|
||||
|
||||
1. every entry is field-for-field identical (engine-relevant fields only,
|
||||
dropping _comment and key order), in the same order, AND
|
||||
2. the same entries fire in the same order for a set of machine-profile
|
||||
fixtures, using the same filter logic the engine uses (filters.py).
|
||||
|
||||
Runs with no database (build_scope produces detached ORM objects whose
|
||||
relationship lists are read directly), so it is cheap to re-run and wrap in CI.
|
||||
Output is one readable line per scope.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from .importer import build_scope
|
||||
from .serializer import scope_to_manifest, canonical_entry
|
||||
from .filters import applicable_entry_names
|
||||
|
||||
_FIXTURES_PATH = os.path.join(os.path.dirname(__file__), 'parityfixtures.json')
|
||||
|
||||
|
||||
def load_fixtures():
|
||||
with open(_FIXTURES_PATH) as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def check_scope(scopename, phase, original, fixtures):
|
||||
"""Compare an original manifest against its import+export round-trip."""
|
||||
rebuilt = scope_to_manifest(build_scope(scopename, phase, original))
|
||||
orig_apps = original.get('Applications') or []
|
||||
rebuilt_apps = rebuilt.get('Applications') or []
|
||||
|
||||
# Check 0: scope-level fields (Version, Site) round-trip.
|
||||
firstdiff = None
|
||||
for key in ('Version', 'Site'):
|
||||
if str(original.get(key) or '') != str(rebuilt.get(key) or ''):
|
||||
firstdiff = (f'scope field {key}: '
|
||||
f'{original.get(key)!r} vs {rebuilt.get(key)!r}')
|
||||
break
|
||||
|
||||
scope_ok = firstdiff is None
|
||||
|
||||
# Check 1: field-identical, order-preserving.
|
||||
identical = 0
|
||||
if firstdiff is None and len(orig_apps) != len(rebuilt_apps):
|
||||
firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}')
|
||||
for i in range(min(len(orig_apps), len(rebuilt_apps))):
|
||||
co = canonical_entry(orig_apps[i])
|
||||
cr = canonical_entry(rebuilt_apps[i])
|
||||
if co == cr:
|
||||
identical += 1
|
||||
elif firstdiff is None:
|
||||
name = orig_apps[i].get('Name', f'#{i}')
|
||||
diffkeys = sorted(
|
||||
k for k in set(co) | set(cr) if co.get(k) != cr.get(k))
|
||||
firstdiff = f'entry "{name}" differs on {diffkeys}'
|
||||
|
||||
# Check 2: same entries fire in the same order for each profile. Inject the
|
||||
# scope phase so PCTypesStrict is honored only for preinstall (see filters).
|
||||
profiles_same = 0
|
||||
for base_profile in fixtures:
|
||||
profile = {**base_profile, 'phase': phase}
|
||||
if (applicable_entry_names(orig_apps, profile)
|
||||
== applicable_entry_names(rebuilt_apps, profile)):
|
||||
profiles_same += 1
|
||||
elif firstdiff is None:
|
||||
firstdiff = f'filter mismatch for profile {profile.get("label")}'
|
||||
|
||||
passed = (scope_ok
|
||||
and identical == len(orig_apps) == len(rebuilt_apps)
|
||||
and profiles_same == len(fixtures))
|
||||
return {
|
||||
'scopename': scopename,
|
||||
'phase': phase,
|
||||
'entries_total': len(orig_apps),
|
||||
'entries_identical': identical,
|
||||
'profiles_total': len(fixtures),
|
||||
'profiles_same': profiles_same,
|
||||
'passed': passed,
|
||||
'firstdiff': firstdiff,
|
||||
}
|
||||
|
||||
|
||||
def run_parity(manifests, fixtures=None):
|
||||
"""Run parity over a list of (scopename, phase, manifest_dict). Returns
|
||||
(results, all_passed)."""
|
||||
fixtures = fixtures if fixtures is not None else load_fixtures()
|
||||
results = [check_scope(name, phase, manifest, fixtures)
|
||||
for name, phase, manifest in manifests]
|
||||
return results, all(r['passed'] for r in results)
|
||||
|
||||
|
||||
def format_result(result):
|
||||
"""One human-readable line for a scope result."""
|
||||
status = 'PASS' if result['passed'] else 'FAIL'
|
||||
line = (f"scope {result['scopename']:<28} "
|
||||
f"entries {result['entries_identical']}/{result['entries_total']} identical "
|
||||
f"profiles {result['profiles_same']}/{result['profiles_total']} same-fire "
|
||||
f"{status}")
|
||||
if not result['passed'] and result['firstdiff']:
|
||||
line += f"\n -> {result['firstdiff']}"
|
||||
return line
|
||||
20
plugins/geenforce/parityfixtures.json
Normal file
20
plugins/geenforce/parityfixtures.json
Normal file
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{"label": "collections-okuma-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0615", "machinenumber": "3201", "cmmversion": ""},
|
||||
{"label": "collections-mtconnect-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC7501", "machinenumber": "7501", "cmmversion": ""},
|
||||
{"label": "collections-other-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0001", "machinenumber": "0001", "cmmversion": ""},
|
||||
{"label": "nocollections", "pctype": "gea-shopfloor-nocollections", "subtype": "", "hostname": "WJPC2000", "machinenumber": "", "cmmversion": ""},
|
||||
{"label": "common-timeclock", "pctype": "gea-shopfloor-common", "subtype": "", "hostname": "WJPC3000", "machinenumber": "", "cmmversion": ""},
|
||||
{"label": "cmm-2016", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM01", "machinenumber": "9101", "cmmversion": "2016"},
|
||||
{"label": "cmm-2019", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM02", "machinenumber": "9102", "cmmversion": "2019"},
|
||||
{"label": "cmm-2026", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM11", "machinenumber": "9111", "cmmversion": "2026"},
|
||||
{"label": "cmm-noversion-legacy", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM99", "machinenumber": "9199", "cmmversion": ""},
|
||||
{"label": "keyence-vr6000", "pctype": "Keyence", "subtype": "vr6000", "hostname": "WJKEY01", "machinenumber": "8001", "cmmversion": ""},
|
||||
{"label": "keyence-vr3000", "pctype": "Keyence", "subtype": "vr3000", "hostname": "WJKEY02", "machinenumber": "8002", "cmmversion": ""},
|
||||
{"label": "genspect", "pctype": "gea-shopfloor-genspect", "subtype": "", "hostname": "WJGEN01", "machinenumber": "8100", "cmmversion": ""},
|
||||
{"label": "heattreat", "pctype": "gea-shopfloor-heattreat", "subtype": "", "hostname": "WJHT01", "machinenumber": "8200", "cmmversion": ""},
|
||||
{"label": "waxtrace", "pctype": "gea-shopfloor-waxtrace", "subtype": "", "hostname": "WJWAX01", "machinenumber": "8300", "cmmversion": ""},
|
||||
{"label": "partmarker", "pctype": "gea-shopfloor-partmarker", "subtype": "", "hostname": "WJPM01", "machinenumber": "8400", "cmmversion": ""},
|
||||
{"label": "display", "pctype": "gea-shopfloor-display", "subtype": "", "hostname": "WJDISP01", "machinenumber": "", "cmmversion": ""},
|
||||
{"label": "legacy-standard-machine", "pctype": "Standard", "subtype": "Machine", "hostname": "WJLEG01", "machinenumber": "3202", "cmmversion": ""},
|
||||
{"label": "legacy-cmm-name", "pctype": "CMM", "subtype": "", "hostname": "WJLEG02", "machinenumber": "9102", "cmmversion": "2019"}
|
||||
]
|
||||
194
plugins/geenforce/plugin.py
Normal file
194
plugins/geenforce/plugin.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""GE-Enforce manifest-store plugin.
|
||||
|
||||
Owns the imaging-PC-type scopes and their install manifests as shopdb data
|
||||
(see the ge-enforce-plugin proposal in the project wiki). This is the P0/P1 foundation: models,
|
||||
the alias-graph seed, and the client-facing manifest endpoint. Authoring UI and
|
||||
client cutover come in later phases.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
import click
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db
|
||||
|
||||
from .api import geenforce_bp
|
||||
from .models import (
|
||||
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
|
||||
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion, ManifestPayload, ManifestEnforcementReport,
|
||||
ManifestEnforcementResult, PcTypeAlias,
|
||||
)
|
||||
from .filters import ALIAS_GROUPS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GeEnforcePlugin(BasePlugin):
|
||||
"""Desired-state manifest store for the GE-Enforce client."""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as handle:
|
||||
return json.load(handle)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'geenforce'),
|
||||
version=self._manifest.get('version', '0.1.0'),
|
||||
description=self._manifest.get('description', 'GE-Enforce manifest store'),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.7.0,<1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/geenforce'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return geenforce_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [
|
||||
ManifestScope, ManifestEntry, ManifestEntryPcType,
|
||||
ManifestEntryHostname, ManifestEntryMachineNumber,
|
||||
ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion, ManifestPayload,
|
||||
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""RBAC permissions this plugin owns (edit vs ship are split)."""
|
||||
return [
|
||||
('geenforce.manage', 'Edit imaging PC types and manifest drafts',
|
||||
'geenforce'),
|
||||
('geenforce.publish', 'Publish, roll back, and export manifests',
|
||||
'geenforce'),
|
||||
('geenforce.fetch', 'Fetch published manifests (client service token)',
|
||||
'geenforce'),
|
||||
('geenforce.report', 'Report enforcement results (client service token)',
|
||||
'geenforce'),
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Top-level sidebar section (GE-Enforce is a large operational surface,
|
||||
not a mere setting). The tabbed shell hosts Manifests + Reports."""
|
||||
return [
|
||||
{
|
||||
'name': 'GE-Enforce',
|
||||
'icon': 'shield',
|
||||
'route': '/geenforce/manifests',
|
||||
'position': 46,
|
||||
},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
with app.app_context():
|
||||
self._seed_aliases()
|
||||
db.session.commit()
|
||||
logger.info("GE-Enforce plugin installed")
|
||||
|
||||
def _seed_aliases(self) -> None:
|
||||
"""Seed pctypealiases from the engine lib's alias graph (idempotent)."""
|
||||
for group_index, group in enumerate(ALIAS_GROUPS):
|
||||
for aliasname in group:
|
||||
exists = PcTypeAlias.query.filter_by(
|
||||
aliasgroup=group_index, aliasname=aliasname).first()
|
||||
if not exists:
|
||||
db.session.add(PcTypeAlias(
|
||||
aliasgroup=group_index, aliasname=aliasname))
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""CLI: parity (Gate A), import-share, export-share, publish."""
|
||||
|
||||
@click.group('geenforce')
|
||||
def geenforce_cli():
|
||||
"""GE-Enforce manifest-store commands."""
|
||||
|
||||
@geenforce_cli.command('parity')
|
||||
@click.option('--shareroot', required=True,
|
||||
help='GE-Enforce share root (contains common/, '
|
||||
'gea-shopfloor-*/).')
|
||||
@click.option('--preinstall', default=None,
|
||||
help='Optional path to a preinstall.json to include.')
|
||||
def parity_cmd(shareroot, preinstall):
|
||||
"""Prove import+export is behaviorally lossless (Gate A). DB-free."""
|
||||
from .importer import discover_share, load_manifest_file
|
||||
from .parity import run_parity, format_result
|
||||
|
||||
manifests = list(discover_share(shareroot))
|
||||
if preinstall:
|
||||
manifests.append(
|
||||
('preinstall', 'preinstall', load_manifest_file(preinstall)))
|
||||
results, ok = run_parity(manifests)
|
||||
for result in results:
|
||||
click.echo(format_result(result))
|
||||
click.echo(f"RESULT: {'PASS' if ok else 'FAIL'} "
|
||||
f"({len(results)} scopes)")
|
||||
raise SystemExit(0 if ok else 1)
|
||||
|
||||
@geenforce_cli.command('import-share')
|
||||
@click.option('--shareroot', required=True)
|
||||
@click.option('--preinstall', default=None)
|
||||
@click.option('--scope', default=None,
|
||||
help='Import only this scope name (else all).')
|
||||
def import_share_cmd(shareroot, preinstall, scope):
|
||||
"""Import on-share manifests into draft rows (idempotent rebuild)."""
|
||||
from flask import current_app
|
||||
from .importer import discover_share, load_manifest_file
|
||||
from .service import replace_scope_draft
|
||||
|
||||
with current_app.app_context():
|
||||
sources = list(discover_share(shareroot))
|
||||
if preinstall:
|
||||
sources.append(('preinstall', 'preinstall',
|
||||
load_manifest_file(preinstall)))
|
||||
count = 0
|
||||
for name, phase, manifest in sources:
|
||||
if scope and name != scope:
|
||||
continue
|
||||
replace_scope_draft(name, phase, manifest)
|
||||
count += 1
|
||||
db.session.commit()
|
||||
click.echo(f"Imported {count} scope(s).")
|
||||
|
||||
@geenforce_cli.command('publish')
|
||||
@click.argument('scopename')
|
||||
@click.option('--phase', default='runtime')
|
||||
@click.option('--notes', default=None)
|
||||
def publish_cmd(scopename, phase, notes):
|
||||
"""Freeze the current draft of a scope into a published snapshot."""
|
||||
from flask import current_app
|
||||
from .service import publish_scope
|
||||
|
||||
with current_app.app_context():
|
||||
version = publish_scope(scopename, phase, notes=notes)
|
||||
db.session.commit()
|
||||
click.echo(f"Published {scopename}/{phase} as v{version}.")
|
||||
|
||||
@geenforce_cli.command('export-share')
|
||||
@click.argument('scopename')
|
||||
@click.option('--shareroot', required=True)
|
||||
@click.option('--phase', default='runtime')
|
||||
def export_share_cmd(scopename, shareroot, phase):
|
||||
"""Write a scope's published JSON to the share (with history backup)."""
|
||||
from flask import current_app
|
||||
from .service import export_scope_to_share
|
||||
|
||||
with current_app.app_context():
|
||||
path = export_scope_to_share(scopename, phase, shareroot)
|
||||
click.echo(f"Exported to {path}.")
|
||||
|
||||
return [geenforce_cli]
|
||||
133
plugins/geenforce/serializer.py
Normal file
133
plugins/geenforce/serializer.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Rebuild manifest JSON from DB rows, and canonicalize entries for parity.
|
||||
|
||||
`scope_to_manifest` renders a ManifestScope back into the exact JSON structure
|
||||
GE-Enforce consumes (Version / _comment / Applications, plus Site for
|
||||
preinstall). `canonical_entry` reduces an Applications[] entry (from either the
|
||||
on-share file or a rebuilt scope) to only the fields the engine reads, dropping
|
||||
`_comment` and key order, so the parity harness can compare behavior not bytes.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
# Scalar entry fields: model attribute -> manifest key. Emitted only when set,
|
||||
# so a rebuilt entry has the same sparse key presence as the original.
|
||||
_SCALAR_FIELDS = [
|
||||
('name', 'Name'),
|
||||
('entrytype', 'Type'),
|
||||
('installer', 'Installer'),
|
||||
('installargs', 'InstallArgs'),
|
||||
('scriptpath', 'Script'),
|
||||
('scriptargs', 'Args'),
|
||||
('sourcepath', 'Source'),
|
||||
('destination', 'Destination'),
|
||||
('regpath', 'RegPath'),
|
||||
('regname', 'RegName'),
|
||||
('regtype', 'RegType'),
|
||||
('detectionmethod', 'DetectionMethod'),
|
||||
('detectionpath', 'DetectionPath'),
|
||||
('detectionname', 'DetectionName'),
|
||||
('detectionvalue', 'DetectionValue'),
|
||||
('detectionpattern', 'DetectionPattern'),
|
||||
('cmmversion', '_CmmVersion'),
|
||||
('logfile', 'LogFile'),
|
||||
('waittimeoutsec', 'WaitTimeoutSec'),
|
||||
('applymode', 'ApplyMode'),
|
||||
('updatewindow', 'UpdateWindow'),
|
||||
]
|
||||
|
||||
# Boolean flags emitted only when True (matches how preinstall.json carries them).
|
||||
_BOOL_FLAGS = [
|
||||
('preenrollment', 'PreEnrollment'),
|
||||
('killafterdetection', 'KillAfterDetection'),
|
||||
('pctypesstrict', 'PCTypesStrict'),
|
||||
]
|
||||
|
||||
# The complete set of engine-relevant keys the parity check compares.
|
||||
ENGINE_KEYS = (
|
||||
[mk for _, mk in _SCALAR_FIELDS]
|
||||
+ [mk for _, mk in _BOOL_FLAGS]
|
||||
+ ['PCTypes', 'TargetHostnames', 'TargetMachineNumbers', 'RegValue', 'InUseCheck']
|
||||
)
|
||||
|
||||
|
||||
def entry_to_dict(entry):
|
||||
"""Render a ManifestEntry model back into a manifest Applications[] entry."""
|
||||
result = {}
|
||||
if entry.comment:
|
||||
result['_comment'] = entry.comment
|
||||
for attr, key in _SCALAR_FIELDS:
|
||||
value = getattr(entry, attr)
|
||||
if value is not None and value != '':
|
||||
result[key] = value
|
||||
# RegValue: stored as a raw JSON literal, reconstitute its real type.
|
||||
if entry.regvalue is not None:
|
||||
result['RegValue'] = json.loads(entry.regvalue)
|
||||
# Multi-value filters (only when present).
|
||||
if entry.pctypes:
|
||||
result['PCTypes'] = [p.pctypevalue for p in entry.pctypes]
|
||||
if entry.hostnames:
|
||||
result['TargetHostnames'] = [h.hostnamepattern for h in entry.hostnames]
|
||||
if entry.machinenumbers:
|
||||
result['TargetMachineNumbers'] = [m.machinenumber
|
||||
for m in entry.machinenumbers]
|
||||
for attr, key in _BOOL_FLAGS:
|
||||
if getattr(entry, attr):
|
||||
result[key] = True
|
||||
# InUseCheck (nested object + Processes[]).
|
||||
if entry.inusecheck:
|
||||
procs = []
|
||||
for proc in entry.inusecheck.processes:
|
||||
pd = {'Name': proc.processname}
|
||||
if proc.exepath is not None:
|
||||
pd['ExePath'] = proc.exepath
|
||||
if proc.gracefulclosetimeoutsec is not None:
|
||||
pd['GracefulCloseTimeoutSec'] = proc.gracefulclosetimeoutsec
|
||||
procs.append(pd)
|
||||
result['InUseCheck'] = {
|
||||
'Behavior': entry.inusecheck.behavior,
|
||||
'Processes': procs,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def scope_to_manifest(scope):
|
||||
"""Render a ManifestScope back into a full manifest.json structure."""
|
||||
manifest = {'Version': scope.manifestversion}
|
||||
if scope.topcomment:
|
||||
manifest['_comment'] = scope.topcomment
|
||||
if scope.site:
|
||||
manifest['Site'] = scope.site
|
||||
manifest['Applications'] = [entry_to_dict(e) for e in scope.entries]
|
||||
return manifest
|
||||
|
||||
|
||||
def scope_to_json(scope, indent=2):
|
||||
"""The serialized JSON text a client/export would receive."""
|
||||
return json.dumps(scope_to_manifest(scope), indent=indent)
|
||||
|
||||
|
||||
def canonical_entry(entry_dict):
|
||||
"""Reduce an Applications[] entry to engine-relevant fields for parity.
|
||||
|
||||
Drops `_comment` and key order. Normalizes InUseCheck and the process list
|
||||
so two entries that behave identically compare equal regardless of source.
|
||||
"""
|
||||
canon = {}
|
||||
for key in ENGINE_KEYS:
|
||||
if key == 'InUseCheck':
|
||||
continue
|
||||
if key in entry_dict and entry_dict[key] is not None and entry_dict[key] != '':
|
||||
canon[key] = entry_dict[key]
|
||||
inuse = entry_dict.get('InUseCheck')
|
||||
if inuse:
|
||||
procs = []
|
||||
for proc in inuse.get('Processes', []):
|
||||
pd = {'Name': proc.get('Name')}
|
||||
if proc.get('ExePath') is not None:
|
||||
pd['ExePath'] = proc.get('ExePath')
|
||||
if proc.get('GracefulCloseTimeoutSec') is not None:
|
||||
pd['GracefulCloseTimeoutSec'] = proc.get('GracefulCloseTimeoutSec')
|
||||
procs.append(pd)
|
||||
canon['InUseCheck'] = {'Behavior': inuse.get('Behavior'),
|
||||
'Processes': procs}
|
||||
return canon
|
||||
343
plugins/geenforce/service.py
Normal file
343
plugins/geenforce/service.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""DB-touching operations for the manifest store.
|
||||
|
||||
Kept out of the CLI and routes so both share one implementation:
|
||||
- `replace_scope_draft` imports/re-imports a scope's DRAFT entries WITHOUT
|
||||
touching its published-version history (re-import is idempotent + safe).
|
||||
- `publish_scope` freezes the current draft into a new immutable snapshot.
|
||||
- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from shopdb.api import db, Application
|
||||
|
||||
from .models import (
|
||||
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
||||
ManifestEnforcementResult, ManifestPayload,
|
||||
)
|
||||
from .importer import build_entry
|
||||
from .serializer import scope_to_json
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _installed_app_model():
|
||||
"""Lazily import the computers plugin's ComputerInstalledApp.
|
||||
|
||||
The computers plugin is optional; importing it lazily (not at module load)
|
||||
keeps geenforce usable when computers is absent or disabled. Mirrors
|
||||
collector._computer_models. Returns the model class, or None if absent.
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.models import ComputerInstalledApp
|
||||
return ComputerInstalledApp
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def replace_scope_draft(scopename, phase, manifest):
|
||||
"""Create/refresh a scope and REPLACE its draft entries. Published versions
|
||||
are left untouched. Returns the scope (uncommitted)."""
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
scope = ManifestScope(scopename=scopename, phase=phase)
|
||||
db.session.add(scope)
|
||||
scope.manifestversion = str(manifest.get('Version', '1.0'))
|
||||
scope.topcomment = manifest.get('_comment')
|
||||
scope.site = manifest.get('Site')
|
||||
scope.iscommon = (scopename == 'common')
|
||||
|
||||
for entry in list(scope.entries):
|
||||
db.session.delete(entry)
|
||||
db.session.flush()
|
||||
|
||||
for i, entry_dict in enumerate(manifest.get('Applications') or []):
|
||||
scope.entries.append(build_entry(entry_dict, i))
|
||||
return scope
|
||||
|
||||
|
||||
def publish_scope(scopename, phase, notes=None, publishedby=None):
|
||||
"""Freeze the current draft into a new published snapshot. Returns the
|
||||
version number (uncommitted)."""
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
raise ValueError(f'No scope {scopename}/{phase}')
|
||||
|
||||
text = scope_to_json(scope)
|
||||
maxversion = db.session.query(
|
||||
func.max(ManifestPublishedVersion.versionnumber)
|
||||
).filter_by(scopeid=scope.scopeid).scalar() or 0
|
||||
|
||||
ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scope.scopeid, iscurrent=True
|
||||
).update({'iscurrent': False})
|
||||
|
||||
db.session.add(ManifestPublishedVersion(
|
||||
scopeid=scope.scopeid,
|
||||
versionnumber=maxversion + 1,
|
||||
manifestjson=text,
|
||||
publishedat=_utcnow(),
|
||||
publishedby=publishedby,
|
||||
iscurrent=True,
|
||||
notes=notes))
|
||||
return maxversion + 1
|
||||
|
||||
|
||||
def rollback_scope(scopename, phase, versionnumber):
|
||||
"""Make an older published version current again (uncommitted)."""
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
raise ValueError(f'No scope {scopename}/{phase}')
|
||||
target = ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scope.scopeid, versionnumber=versionnumber).first()
|
||||
if not target:
|
||||
raise ValueError(f'No version {versionnumber} for {scopename}')
|
||||
ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scope.scopeid, iscurrent=True
|
||||
).update({'iscurrent': False})
|
||||
target.iscurrent = True
|
||||
return versionnumber
|
||||
|
||||
|
||||
def record_enforcement_report(payload):
|
||||
"""Record one PC's enforcement cycle (observed state). Upserts the latest
|
||||
report per (hostname, scopename, phase) and keeps prior ones as history.
|
||||
|
||||
Payload (all but hostname optional):
|
||||
hostname, scopename, phase, appliedversion, enforcerversion, lastcheckin,
|
||||
counts {installed, skipped, failed, filtered},
|
||||
results [{name, action, selfhealed, exitcode, message}]
|
||||
|
||||
Returns the new ManifestEnforcementReport (uncommitted).
|
||||
"""
|
||||
hostname = (payload.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
raise ValueError('hostname is required')
|
||||
scopename = (payload.get('scopename') or '').strip()
|
||||
phase = (payload.get('phase') or 'runtime').strip()
|
||||
counts = payload.get('counts') or {}
|
||||
results = payload.get('results') or []
|
||||
# Guard wrong JSON shapes: a bad type must be a clean 400, not a 500 from a
|
||||
# later .get()/iteration (the route maps ValueError to 400).
|
||||
if not isinstance(counts, dict):
|
||||
raise ValueError('counts must be an object')
|
||||
if not isinstance(results, list):
|
||||
raise ValueError('results must be a list')
|
||||
|
||||
failed = int(counts.get('failed', 0))
|
||||
installed = int(counts.get('installed', 0))
|
||||
# Derive status from EXPLICIT self-heal flags only, never the raw installed
|
||||
# count: every healthy cycle runs Always/no-detection scripts (asset report,
|
||||
# VNC firewall, EventSaver) that the engine counts as "installed", so keying
|
||||
# self-heal off installed>0 would mark the common scope selfhealed forever
|
||||
# and make 'ok' unreachable. A drift correction is one the client flags.
|
||||
if failed > 0:
|
||||
status = 'failed'
|
||||
elif any(r.get('selfhealed') for r in results):
|
||||
status = 'selfhealed'
|
||||
else:
|
||||
status = 'ok'
|
||||
|
||||
# Demote the prior current report for this host+scope. Hostname match is
|
||||
# case-insensitive to match the ilike read path - otherwise a PC reporting
|
||||
# its name in different casing would leave two iscurrent rows.
|
||||
ManifestEnforcementReport.query.filter(
|
||||
func.lower(ManifestEnforcementReport.hostname) == hostname.lower(),
|
||||
ManifestEnforcementReport.scopename == scopename,
|
||||
ManifestEnforcementReport.phase == phase,
|
||||
ManifestEnforcementReport.iscurrent == True
|
||||
).update({'iscurrent': False}, synchronize_session=False)
|
||||
|
||||
report = ManifestEnforcementReport(
|
||||
hostname=hostname,
|
||||
scopename=scopename,
|
||||
phase=phase,
|
||||
appliedversion=payload.get('appliedversion'),
|
||||
enforcerversion=payload.get('enforcerversion'),
|
||||
installedcount=installed,
|
||||
skippedcount=int(counts.get('skipped', 0)),
|
||||
failedcount=failed,
|
||||
filteredcount=int(counts.get('filtered', 0)),
|
||||
status=status,
|
||||
lastcheckin=_parse_dt(payload.get('lastcheckin')),
|
||||
receivedat=_utcnow(),
|
||||
iscurrent=True)
|
||||
for item in results:
|
||||
report.results.append(ManifestEnforcementResult(
|
||||
entryname=item.get('name', ''),
|
||||
action=item.get('action', ''),
|
||||
# Explicit flag only; do NOT infer from action == 'installed'
|
||||
# (Always/no-detection scripts install every cycle without being a
|
||||
# drift correction).
|
||||
selfhealed=bool(item.get('selfhealed', False)),
|
||||
exitcode=item.get('exitcode'),
|
||||
message=item.get('message')))
|
||||
db.session.add(report)
|
||||
return report
|
||||
|
||||
|
||||
def _parse_dt(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace('Z', '+00:00')).replace(
|
||||
tzinfo=None)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def compliance_for_scope(scope):
|
||||
"""Fleet-install coverage for a scope's app-linked entries.
|
||||
|
||||
One row per ManifestEntry that carries a curated appid (unlinked entries
|
||||
are skipped), ordered by sortorder. Counts come from the computers plugin's
|
||||
ComputerInstalledApp (active rows only, one row per PC per app). Degrades
|
||||
gracefully with null counts when the computers plugin is absent.
|
||||
|
||||
Returns the response data dict (scopeid/scopename/phase/computersplugin/rows).
|
||||
"""
|
||||
linked = [e for e in sorted(scope.entries, key=lambda e: e.sortorder)
|
||||
if e.appid is not None]
|
||||
installedmodel = _installed_app_model()
|
||||
computerspresent = installedmodel is not None
|
||||
|
||||
# One grouped query for the whole scope's appid set (fleet is tiny).
|
||||
installedbyapp = {}
|
||||
if computerspresent and linked:
|
||||
appids = {e.appid for e in linked}
|
||||
counted = db.session.query(
|
||||
installedmodel.appid, func.count(installedmodel.id)
|
||||
).filter(
|
||||
installedmodel.isactive == True,
|
||||
installedmodel.appid.in_(appids),
|
||||
).group_by(installedmodel.appid).all()
|
||||
installedbyapp = {appid: count for appid, count in counted}
|
||||
|
||||
rows = []
|
||||
for entry in linked:
|
||||
app = db.session.get(Application, entry.appid)
|
||||
appname = app.appname if app else None
|
||||
expectedversion = (entry.detectionvalue
|
||||
if entry.detectionmethod == 'FileVersion' else None)
|
||||
|
||||
if not computerspresent:
|
||||
installedcount = None
|
||||
versionmatchcount = None
|
||||
coveragenote = ('computers plugin not installed; install counts '
|
||||
'unavailable')
|
||||
else:
|
||||
installedcount = installedbyapp.get(entry.appid, 0)
|
||||
if installedcount == 0:
|
||||
versionmatchcount = None if expectedversion is None else 0
|
||||
coveragenote = 'not installed on any collected PC'
|
||||
elif expectedversion is None:
|
||||
versionmatchcount = None
|
||||
method = entry.detectionmethod or 'none'
|
||||
coveragenote = (f'installed on {installedcount} PC(s); no '
|
||||
f'version target (detection is {method})')
|
||||
else:
|
||||
versionmatchcount = db.session.query(
|
||||
func.count(installedmodel.id)
|
||||
).filter(
|
||||
installedmodel.isactive == True,
|
||||
installedmodel.appid == entry.appid,
|
||||
installedmodel.installedversion == expectedversion,
|
||||
).scalar() or 0
|
||||
coveragenote = (f'{versionmatchcount} of {installedcount} '
|
||||
'collected PCs on the expected version')
|
||||
|
||||
rows.append({
|
||||
'entryid': entry.entryid,
|
||||
'entryname': entry.name,
|
||||
'appid': entry.appid,
|
||||
'appname': appname,
|
||||
'expectedversion': expectedversion,
|
||||
'installedcount': installedcount,
|
||||
'versionmatchcount': versionmatchcount,
|
||||
'coveragenote': coveragenote,
|
||||
})
|
||||
|
||||
return {
|
||||
'scopeid': scope.scopeid,
|
||||
'scopename': scope.scopename,
|
||||
'phase': scope.phase,
|
||||
'computersplugin': computerspresent,
|
||||
'rows': rows,
|
||||
}
|
||||
|
||||
|
||||
def store_inline_payload(entry, filename, contenttype, rawbytes):
|
||||
"""Replace the entry's inline payload with these bytes (uncommitted).
|
||||
|
||||
Computes payloadsha256, upserts the single ManifestPayload row (the table
|
||||
has no unique constraint on entryid, so the one-inline-payload-per-entry
|
||||
rule is enforced here), and points the entry at it (payloadsource='inline').
|
||||
Returns the ManifestPayload.
|
||||
"""
|
||||
sha = hashlib.sha256(rawbytes).hexdigest()
|
||||
ManifestPayload.query.filter_by(entryid=entry.entryid).delete()
|
||||
db.session.flush()
|
||||
payload = ManifestPayload(
|
||||
entryid=entry.entryid,
|
||||
filename=filename,
|
||||
contenttype=contenttype,
|
||||
payloadbytes=rawbytes,
|
||||
payloadsha256=sha,
|
||||
uploadedat=_utcnow())
|
||||
db.session.add(payload)
|
||||
entry.payloadsource = 'inline'
|
||||
entry.payloadref = filename
|
||||
entry.payloadsha256 = sha
|
||||
return payload
|
||||
|
||||
|
||||
def export_scope_to_share(scopename, phase, shareroot):
|
||||
"""Write a scope's current published JSON to the share, backing up the old
|
||||
file to _meta/history first. Returns the written path."""
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
raise ValueError(f'No scope {scopename}/{phase}')
|
||||
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
if not published:
|
||||
raise ValueError(f'{scopename} has no published version')
|
||||
|
||||
if phase == 'preinstall':
|
||||
target = os.path.join(shareroot, 'preinstall.json')
|
||||
else:
|
||||
target = os.path.join(shareroot, scopename, 'manifest.json')
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
|
||||
if os.path.isfile(target):
|
||||
historydir = os.path.join(shareroot, '_meta', 'history')
|
||||
os.makedirs(historydir, exist_ok=True)
|
||||
stamp = _utcnow().strftime('%Y%m%d-%H%M%S')
|
||||
with open(target) as src:
|
||||
old = src.read()
|
||||
with open(os.path.join(historydir, f'{stamp}-{scopename}.json'), 'w') as dst:
|
||||
dst.write(old)
|
||||
|
||||
# Atomic write: a partial/failed write must never leave the live on-share
|
||||
# manifest (which every PC reads) truncated. Write a temp file in the same
|
||||
# directory, then rename over the target.
|
||||
targetdir = os.path.dirname(target)
|
||||
fd, tmppath = tempfile.mkstemp(dir=targetdir, suffix='.tmp')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as handle:
|
||||
handle.write(published.manifestjson)
|
||||
os.replace(tmppath, target)
|
||||
except Exception:
|
||||
if os.path.exists(tmppath):
|
||||
os.remove(tmppath)
|
||||
raise
|
||||
return target
|
||||
5
plugins/knowledgebase/__init__.py
Normal file
5
plugins/knowledgebase/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Knowledge Base plugin package."""
|
||||
|
||||
from .plugin import KnowledgeBasePlugin
|
||||
|
||||
__all__ = ['KnowledgeBasePlugin']
|
||||
5
plugins/knowledgebase/api/__init__.py
Normal file
5
plugins/knowledgebase/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Knowledge Base plugin API."""
|
||||
|
||||
from .routes import knowledgebase_bp
|
||||
|
||||
__all__ = ['knowledgebase_bp']
|
||||
224
plugins/knowledgebase/api/routes.py
Normal file
224
plugins/knowledgebase/api/routes.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Knowledge Base API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
Application,
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
|
||||
from ..models import KnowledgeBase
|
||||
|
||||
from shopdb.api import require_permission, apply_import_timestamps
|
||||
|
||||
knowledgebase_bp = Blueprint('knowledgebase', __name__)
|
||||
|
||||
|
||||
@knowledgebase_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_articles():
|
||||
"""List all knowledge base articles."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = KnowledgeBase.query.filter_by(isactive=True)
|
||||
|
||||
# Search
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
KnowledgeBase.shortdescription.ilike(f'%{search}%'),
|
||||
KnowledgeBase.keywords.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by topic/application
|
||||
if appid := request.args.get('appid'):
|
||||
query = query.filter(KnowledgeBase.appid == int(appid))
|
||||
|
||||
# Exact-match natural-key lookups for idempotent import. linkurl is the
|
||||
# stable natural key; shortdescription (the title) is offered as a fallback.
|
||||
if exactlinkurl := request.args.get('linkurl'):
|
||||
query = query.filter(KnowledgeBase.linkurl == exactlinkurl)
|
||||
if exacttitle := request.args.get('shortdescription'):
|
||||
query = query.filter(KnowledgeBase.shortdescription == exacttitle)
|
||||
|
||||
# Sort options
|
||||
sort = request.args.get('sort', 'clicks')
|
||||
order = request.args.get('order', 'desc')
|
||||
|
||||
if sort == 'clicks':
|
||||
query = query.order_by(
|
||||
KnowledgeBase.clicks.desc() if order == 'desc' else KnowledgeBase.clicks.asc(),
|
||||
KnowledgeBase.lastupdated.desc()
|
||||
)
|
||||
elif sort == 'topic':
|
||||
query = query.join(Application).order_by(
|
||||
Application.appname.desc() if order == 'desc' else Application.appname.asc()
|
||||
)
|
||||
elif sort == 'description':
|
||||
query = query.order_by(
|
||||
KnowledgeBase.shortdescription.desc() if order == 'desc' else KnowledgeBase.shortdescription.asc()
|
||||
)
|
||||
elif sort == 'lastupdated':
|
||||
query = query.order_by(
|
||||
KnowledgeBase.lastupdated.desc() if order == 'desc' else KnowledgeBase.lastupdated.asc()
|
||||
)
|
||||
else:
|
||||
query = query.order_by(KnowledgeBase.clicks.desc())
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = []
|
||||
for article in items:
|
||||
article_dict = article.to_dict()
|
||||
if article.application:
|
||||
article_dict['application'] = {
|
||||
'appid': article.application.appid,
|
||||
'appname': article.application.appname
|
||||
}
|
||||
else:
|
||||
article_dict['application'] = None
|
||||
data.append(article_dict)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/stats', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_stats():
|
||||
"""Get knowledge base statistics."""
|
||||
total_clicks = db.session.query(
|
||||
db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0)
|
||||
).filter(KnowledgeBase.isactive == True).scalar()
|
||||
|
||||
total_articles = KnowledgeBase.query.filter_by(isactive=True).count()
|
||||
|
||||
return success_response({
|
||||
'totalclicks': int(total_clicks),
|
||||
'totalarticles': total_articles
|
||||
})
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_article(link_id: int):
|
||||
"""Get a single knowledge base article."""
|
||||
article = db.session.get(KnowledgeBase, link_id)
|
||||
|
||||
if not article or not article.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
|
||||
|
||||
data = article.to_dict()
|
||||
if article.application:
|
||||
data['application'] = {
|
||||
'appid': article.application.appid,
|
||||
'appname': article.application.appname
|
||||
}
|
||||
else:
|
||||
data['application'] = None
|
||||
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>/click', methods=['POST'])
|
||||
@jwt_required(optional=True)
|
||||
def track_click(link_id: int):
|
||||
"""Increment click counter and return the URL to redirect to."""
|
||||
article = db.session.get(KnowledgeBase, link_id)
|
||||
|
||||
if not article or not article.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
|
||||
|
||||
article.increment_clicks()
|
||||
db.session.commit()
|
||||
|
||||
return success_response({
|
||||
'linkurl': article.linkurl,
|
||||
'clicks': article.clicks
|
||||
})
|
||||
|
||||
|
||||
@knowledgebase_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('kb.create')
|
||||
def create_article():
|
||||
"""Create a new knowledge base article."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('shortdescription'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'shortdescription is required')
|
||||
|
||||
if not data.get('linkurl'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'linkurl is required')
|
||||
|
||||
# Validate application if provided
|
||||
if data.get('appid'):
|
||||
app = db.session.get(Application, data['appid'])
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
article = KnowledgeBase(
|
||||
shortdescription=data['shortdescription'],
|
||||
linkurl=data['linkurl'],
|
||||
appid=data.get('appid'),
|
||||
keywords=data.get('keywords'),
|
||||
clicks=0
|
||||
)
|
||||
|
||||
db.session.add(article)
|
||||
apply_import_timestamps(article, data)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(article.to_dict(), message='Article created', http_code=201)
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('kb.edit')
|
||||
def update_article(link_id: int):
|
||||
"""Update a knowledge base article."""
|
||||
article = db.session.get(KnowledgeBase, link_id)
|
||||
|
||||
if not article:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Validate application if being changed
|
||||
if 'appid' in data and data['appid']:
|
||||
app = db.session.get(Application, data['appid'])
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Application not found', http_code=404)
|
||||
|
||||
fields = ['shortdescription', 'linkurl', 'appid', 'keywords', 'isactive']
|
||||
for key in fields:
|
||||
if key in data:
|
||||
setattr(article, key, data[key])
|
||||
|
||||
apply_import_timestamps(article, data)
|
||||
db.session.commit()
|
||||
return success_response(article.to_dict(), message='Article updated')
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('kb.delete')
|
||||
def delete_article(link_id: int):
|
||||
"""Delete (deactivate) a knowledge base article."""
|
||||
article = db.session.get(KnowledgeBase, link_id)
|
||||
|
||||
if not article:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
|
||||
|
||||
article.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Article deleted')
|
||||
13
plugins/knowledgebase/manifest.json
Normal file
13
plugins/knowledgebase/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "knowledgebase",
|
||||
"display_name": "Knowledge Base",
|
||||
"version": "1.0.0",
|
||||
"description": "Knowledge Base articles linking to external resources",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/knowledgebase",
|
||||
"provides": {
|
||||
"features": ["knowledgebase"]
|
||||
}
|
||||
}
|
||||
14
plugins/knowledgebase/migrations/env.py
Normal file
14
plugins/knowledgebase/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the knowledgebase plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which
|
||||
filters the metadata to this plugin's tables and drives Alembic against the
|
||||
per-plugin version table alembic_version_knowledgebase. See ADR-008 for the
|
||||
ownership cutover between the core chain and per-plugin chains.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'knowledgebase'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/knowledgebase/migrations/script.py.mako
Normal file
24
plugins/knowledgebase/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""knowledgebase plugin anchor (ownership cutover).
|
||||
|
||||
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
|
||||
already created every table this plugin owns at the cutover point, so there is
|
||||
nothing to build here. This revision gives the plugin's own chain a base that
|
||||
`flask plugin upgrade-all` can stamp into alembic_version_knowledgebase. From this
|
||||
anchor forward, new knowledgebase schema changes land as 000N revisions in this
|
||||
directory, never in the core chain. See ADR-008.
|
||||
"""
|
||||
from alembic import op # noqa: F401
|
||||
import sqlalchemy as sa # noqa: F401
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'knowledgebase0001anchor'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No-op: the core chain owns every table that exists at the cutover.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No-op: this anchor never unwinds core-owned tables.
|
||||
pass
|
||||
5
plugins/knowledgebase/models/__init__.py
Normal file
5
plugins/knowledgebase/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Knowledge Base plugin models."""
|
||||
|
||||
from .knowledgebase import KnowledgeBase
|
||||
|
||||
__all__ = ['KnowledgeBase']
|
||||
40
plugins/knowledgebase/models/knowledgebase.py
Normal file
40
plugins/knowledgebase/models/knowledgebase.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Knowledge Base model.
|
||||
|
||||
Non-asset plugin model. The `knowledgebase` table lives in the core Alembic
|
||||
chain (bundled-plugin schema is folded into core, ADR-004); this class just
|
||||
maps it and is registered via the plugin's get_models hook. The appid FK
|
||||
references the core applications table by name, which resolves at mapper config
|
||||
time without importing the core model.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
# naive UTC to match the other DB DateTime columns (stored without tzinfo)
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class KnowledgeBase(BaseModel):
|
||||
"""Knowledge Base article linking to external resources."""
|
||||
__tablename__ = 'knowledgebase'
|
||||
|
||||
linkid = db.Column(db.Integer, primary_key=True)
|
||||
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'))
|
||||
shortdescription = db.Column(db.String(500), nullable=False)
|
||||
linkurl = db.Column(db.String(2000))
|
||||
keywords = db.Column(db.String(500))
|
||||
clicks = db.Column(db.Integer, default=0)
|
||||
lastupdated = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
# Relationship to the core Application model (resolved by class name).
|
||||
application = db.relationship('Application', backref=db.backref('knowledgebase_articles', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<KnowledgeBase {self.linkid}: {self.shortdescription[:50] if self.shortdescription else 'No desc'}>"
|
||||
|
||||
def increment_clicks(self):
|
||||
"""Increment click counter."""
|
||||
self.clicks = (self.clicks or 0) + 1
|
||||
83
plugins/knowledgebase/plugin.py
Normal file
83
plugins/knowledgebase/plugin.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Knowledge Base plugin main class.
|
||||
|
||||
Reference NON-asset plugin: contributes a model + blueprint + nav item but no
|
||||
AssetType (it is not an asset). Mirrors the notifications plugin.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
from .models import KnowledgeBase
|
||||
from .api import knowledgebase_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KnowledgeBasePlugin(BasePlugin):
|
||||
"""Knowledge Base plugin - articles linking to external resources."""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'knowledgebase'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Knowledge Base articles linking to external resources'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/knowledgebase'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return knowledgebase_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [KnowledgeBase]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Knowledge Base plugin initialized (v{self.meta.version})")
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Knowledge Base',
|
||||
'icon': 'book-open',
|
||||
'route': '/knowledgebase',
|
||||
'position': 35,
|
||||
'section': 'information',
|
||||
},
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""Return the RBAC permissions this plugin owns."""
|
||||
return [
|
||||
('kb.view', 'View knowledge base', 'knowledgebase'),
|
||||
('kb.create', 'Create KB articles', 'knowledgebase'),
|
||||
('kb.edit', 'Edit KB articles', 'knowledgebase'),
|
||||
('kb.delete', 'Delete KB articles', 'knowledgebase'),
|
||||
]
|
||||
5
plugins/machines/__init__.py
Normal file
5
plugins/machines/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Machines plugin for ShopDB."""
|
||||
|
||||
from .plugin import MachinesPlugin
|
||||
|
||||
__all__ = ['MachinesPlugin']
|
||||
5
plugins/machines/api/__init__.py
Normal file
5
plugins/machines/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Machines plugin API."""
|
||||
|
||||
from .routes import machines_bp
|
||||
|
||||
__all__ = ['machines_bp']
|
||||
550
plugins/machines/api/routes.py
Normal file
550
plugins/machines/api/routes.py
Normal file
@@ -0,0 +1,550 @@
|
||||
"""Machines plugin API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, Asset, AssetType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled
|
||||
|
||||
from ..models import Machine, MachineType
|
||||
|
||||
from shopdb.api import require_permission, apply_import_timestamps
|
||||
|
||||
machines_bp = Blueprint('machines', __name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Machine Types
|
||||
# =============================================================================
|
||||
|
||||
@machines_bp.route('/types', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_machine_types():
|
||||
"""List all machine types."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = MachineType.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(MachineType.isactive == True)
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(MachineType.machinetype.ilike(f'%{search}%'))
|
||||
|
||||
query = query.order_by(MachineType.machinetype)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [t.to_dict() for t in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@machines_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_machine_type(type_id: int):
|
||||
"""Get a single machine type."""
|
||||
t = db.session.get(MachineType, type_id)
|
||||
|
||||
if not t:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine type with ID {type_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(t.to_dict())
|
||||
|
||||
|
||||
@machines_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.create')
|
||||
def create_machine_type():
|
||||
"""Create a new machine type."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('machinetype'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required')
|
||||
|
||||
existing = MachineType.query.filter_by(machinetype=data['machinetype']).first()
|
||||
if existing:
|
||||
if not existing.isactive:
|
||||
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 type')
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Machine type '{data['machinetype']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
t = MachineType(
|
||||
machinetype=data['machinetype'],
|
||||
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='Machine type created', http_code=201)
|
||||
|
||||
|
||||
@machines_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.edit')
|
||||
def update_machine_type(type_id: int):
|
||||
"""Update a machine type."""
|
||||
t = db.session.get(MachineType, type_id)
|
||||
|
||||
if not t:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine 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 'machinetype' in data and data['machinetype'] != t.machinetype:
|
||||
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Machine type '{data['machinetype']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
for key in ['machinetype', 'description', 'icon', 'color', 'isactive']:
|
||||
if key in data:
|
||||
setattr(t, key, data[key])
|
||||
|
||||
db.session.commit()
|
||||
return success_response(t.to_dict(), message='Machine type updated')
|
||||
|
||||
|
||||
@machines_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.delete')
|
||||
def delete_machine_type(type_id: int):
|
||||
"""Delete a machine type. Refused if any asset still uses it."""
|
||||
t = db.session.get(MachineType, type_id)
|
||||
if not t:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Machine type not found', http_code=404)
|
||||
inuse = Machine.query.filter_by(machinetypeid=type_id).count()
|
||||
if inuse:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Cannot delete: {inuse} asset(s) still use this type", http_code=409)
|
||||
db.session.delete(t)
|
||||
db.session.commit()
|
||||
return success_response(message='Machine type deleted')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Machine CRUD
|
||||
# =============================================================================
|
||||
|
||||
@machines_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_machines():
|
||||
"""
|
||||
List all machines with filtering and pagination.
|
||||
|
||||
Query parameters:
|
||||
- page, per_page: Pagination
|
||||
- active: Filter by active status
|
||||
- search: Search by asset number or name
|
||||
- type_id: Filter by machine type ID
|
||||
- vendor_id: Filter by vendor ID
|
||||
- location_id: Filter by location ID
|
||||
- businessunit_id: Filter by business unit ID
|
||||
"""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
# Join Machine with Asset
|
||||
query = db.session.query(Machine).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
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Asset.assetnumber.ilike(f'%{search}%'),
|
||||
Asset.name.ilike(f'%{search}%'),
|
||||
Asset.serialnumber.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
# Machine type filter
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Machine.machinetypeid == int(type_id))
|
||||
|
||||
# Vendor filter
|
||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
||||
query = query.filter(Machine.vendorid == int(vendor_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))
|
||||
|
||||
# Sorting
|
||||
sort_by = request.args.get('sort', 'assetnumber')
|
||||
sort_dir = request.args.get('dir', 'asc')
|
||||
|
||||
if sort_by == 'assetnumber':
|
||||
col = Asset.assetnumber
|
||||
elif sort_by == 'name':
|
||||
col = Asset.name
|
||||
else:
|
||||
col = Asset.assetnumber
|
||||
|
||||
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
|
||||
|
||||
# Dualpath single-machine collapse: hide the secondary bay so a dual-bay
|
||||
# pair lists (and paginates) as one machine. Excluding before pagination
|
||||
# keeps totals honest. Gated on the site setting (default on).
|
||||
collapse = None
|
||||
if dualpath_single_machine_enabled():
|
||||
collapse = resolve_dualpath_pairs()
|
||||
if collapse.secondaryassetids:
|
||||
query = query.filter(Asset.assetid.notin_(collapse.secondaryassetids))
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
|
||||
# Resolve partner machineids for this page's primaries in one query.
|
||||
machineidbyasset = {}
|
||||
if collapse:
|
||||
partnerassetids = [
|
||||
collapse.partnerbyasset[m.assetid]['assetid']
|
||||
for m in items if m.assetid in collapse.partnerbyasset
|
||||
]
|
||||
if partnerassetids:
|
||||
for partner in Machine.query.filter(
|
||||
Machine.assetid.in_(partnerassetids)).all():
|
||||
machineidbyasset[partner.assetid] = partner.machineid
|
||||
|
||||
# Build response with both asset and machine data
|
||||
data = []
|
||||
for mach in items:
|
||||
item = mach.asset.to_dict() if mach.asset else {}
|
||||
item['machine'] = mach.to_dict()
|
||||
# annotate the visible bay with its hidden partner (for '2007 / 2008')
|
||||
partner = collapse.partnerbyasset.get(mach.assetid) if collapse else None
|
||||
item['dualpathpartner'] = {
|
||||
'assetid': partner['assetid'],
|
||||
'machineid': machineidbyasset.get(partner['assetid']),
|
||||
'assetnumber': partner['assetnumber'],
|
||||
} if partner else None
|
||||
data.append(item)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
def _dualpath_partner_for(assetid):
|
||||
"""Resolve this machine's Dualpath sibling, or None. Always evaluated
|
||||
(independent of the collapse toggle) so the detail-page banner shows even
|
||||
when a site lists both bays. Returns {assetid, machineid, assetnumber}."""
|
||||
partner = resolve_dualpath_pairs().partnerbyasset.get(assetid)
|
||||
if not partner:
|
||||
return None
|
||||
partnermach = Machine.query.filter_by(assetid=partner['assetid']).first()
|
||||
return {
|
||||
'assetid': partner['assetid'],
|
||||
'machineid': partnermach.machineid if partnermach else None,
|
||||
'assetnumber': partner['assetnumber'],
|
||||
}
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_machine(machine_id: int):
|
||||
"""Get a single machine item with full details."""
|
||||
mach = db.session.get(Machine, machine_id)
|
||||
|
||||
if not mach:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine with ID {machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
result = mach.asset.to_dict() if mach.asset else {}
|
||||
result['machine'] = mach.to_dict()
|
||||
result['dualpathpartner'] = _dualpath_partner_for(mach.assetid)
|
||||
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@machines_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_machine_by_asset(asset_id: int):
|
||||
"""Get machine data by asset ID."""
|
||||
mach = Machine.query.filter_by(assetid=asset_id).first()
|
||||
|
||||
if not mach:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine for asset {asset_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
result = mach.asset.to_dict() if mach.asset else {}
|
||||
result['machine'] = mach.to_dict()
|
||||
result['dualpathpartner'] = _dualpath_partner_for(mach.assetid)
|
||||
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@machines_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.create')
|
||||
def create_machine():
|
||||
"""
|
||||
Create new machine (creates both Asset and Machine records).
|
||||
|
||||
Required fields:
|
||||
- assetnumber: Business identifier
|
||||
|
||||
Optional fields:
|
||||
- name, serialnumber, statusid, locationid, businessunitid
|
||||
- machinetypeid, vendorid, modelnumberid
|
||||
- requiresmanualconfig, islocationonly
|
||||
- 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
|
||||
)
|
||||
|
||||
# Get machine asset type
|
||||
machine_type = AssetType.query.filter_by(assettype='machine').first()
|
||||
if not machine_type:
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Machine 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'),
|
||||
gaugelabreference=data.get('gaugelabreference'),
|
||||
maintenancereference=data.get('maintenancereference'),
|
||||
serialnumber=data.get('serialnumber'),
|
||||
assettypeid=machine_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 machine extension
|
||||
mach = Machine(
|
||||
assetid=asset.assetid,
|
||||
machinetypeid=data.get('machinetypeid'),
|
||||
vendorid=data.get('vendorid'),
|
||||
modelnumberid=data.get('modelnumberid'),
|
||||
requiresmanualconfig=data.get('requiresmanualconfig', False),
|
||||
islocationonly=data.get('islocationonly', False),
|
||||
lastmaintenancedate=data.get('lastmaintenancedate'),
|
||||
nextmaintenancedate=data.get('nextmaintenancedate'),
|
||||
maintenanceintervaldays=data.get('maintenanceintervaldays'),
|
||||
controllervendorid=data.get('controllervendorid'),
|
||||
controllermodelid=data.get('controllermodelid')
|
||||
)
|
||||
|
||||
db.session.add(mach)
|
||||
db.session.flush()
|
||||
|
||||
# Preserve legacy timestamps in import mode (no-op otherwise)
|
||||
apply_import_timestamps(asset, data)
|
||||
|
||||
# Audit log
|
||||
AuditLog.log('created', 'Machine', entityid=mach.machineid,
|
||||
entityname=data['assetnumber'])
|
||||
|
||||
db.session.commit()
|
||||
|
||||
result = asset.to_dict()
|
||||
result['machine'] = mach.to_dict()
|
||||
|
||||
return success_response(result, message='Machine created', http_code=201)
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.edit')
|
||||
def update_machine(machine_id: int):
|
||||
"""Update machine (both Asset and Machine records)."""
|
||||
mach = db.session.get(Machine, machine_id)
|
||||
|
||||
if not mach:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine with ID {machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
asset = mach.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
|
||||
)
|
||||
|
||||
# Track changes for audit log
|
||||
changes = {}
|
||||
|
||||
# Update asset fields
|
||||
asset_fields = ['assetnumber', 'name', 'gaugelabreference',
|
||||
'maintenancereference', 'serialnumber', '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 machine fields
|
||||
machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid',
|
||||
'requiresmanualconfig', 'islocationonly',
|
||||
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
|
||||
'controllervendorid', 'controllermodelid']
|
||||
for key in machine_fields:
|
||||
if key in data:
|
||||
old_val = getattr(mach, key)
|
||||
new_val = data[key]
|
||||
if old_val != new_val:
|
||||
changes[key] = {'old': old_val, 'new': new_val}
|
||||
setattr(mach, key, data[key])
|
||||
|
||||
# Audit log if there were changes
|
||||
if changes:
|
||||
AuditLog.log('updated', 'Machine', entityid=mach.machineid,
|
||||
entityname=asset.assetnumber, changes=changes)
|
||||
|
||||
apply_import_timestamps(asset, data)
|
||||
db.session.commit()
|
||||
|
||||
result = asset.to_dict()
|
||||
result['machine'] = mach.to_dict()
|
||||
|
||||
return success_response(result, message='Machine updated')
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.delete')
|
||||
def delete_machine(machine_id: int):
|
||||
"""Delete (soft delete) machine."""
|
||||
mach = db.session.get(Machine, machine_id)
|
||||
|
||||
if not mach:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Machine with ID {machine_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
# Soft delete the asset (machine extension will stay linked)
|
||||
mach.asset.isactive = False
|
||||
|
||||
# Audit log
|
||||
AuditLog.log('deleted', 'Machine', entityid=mach.machineid,
|
||||
entityname=mach.asset.assetnumber)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Machine deleted')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dashboard
|
||||
# =============================================================================
|
||||
|
||||
@machines_bp.route('/dashboard/summary', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def dashboard_summary():
|
||||
"""Get machine dashboard summary data."""
|
||||
# Dualpath single-machine collapse: hide the secondary bay from counts so a
|
||||
# dual-bay pair counts once. Gated on the site setting (default on).
|
||||
secondaryassetids = set()
|
||||
if dualpath_single_machine_enabled():
|
||||
secondaryassetids = resolve_dualpath_pairs().secondaryassetids
|
||||
|
||||
# Total active machine count
|
||||
total_query = db.session.query(Machine).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
)
|
||||
if secondaryassetids:
|
||||
total_query = total_query.filter(Asset.assetid.notin_(secondaryassetids))
|
||||
total = total_query.count()
|
||||
|
||||
# Count by machine type
|
||||
by_type_query = db.session.query(
|
||||
MachineType.machinetype,
|
||||
db.func.count(Machine.machineid)
|
||||
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
|
||||
).join(Asset, Asset.assetid == Machine.assetid
|
||||
).filter(Asset.isactive == True)
|
||||
if secondaryassetids:
|
||||
by_type_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids))
|
||||
by_type = by_type_query.group_by(MachineType.machinetype).all()
|
||||
|
||||
# Count by status
|
||||
from shopdb.api import AssetStatus
|
||||
by_status = db.session.query(
|
||||
AssetStatus.status,
|
||||
db.func.count(Machine.machineid)
|
||||
).join(Asset, Asset.assetid == Machine.assetid
|
||||
).join(AssetStatus, AssetStatus.statusid == Asset.statusid
|
||||
).filter(Asset.isactive == True
|
||||
).group_by(AssetStatus.status
|
||||
).all()
|
||||
|
||||
return success_response({
|
||||
'total': total,
|
||||
'bytype': [{'type': t, 'count': c} for t, c in by_type],
|
||||
'bystatus': [{'status': s, 'count': c} for s, c in by_status]
|
||||
})
|
||||
22
plugins/machines/manifest.json
Normal file
22
plugins/machines/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "machines",
|
||||
"version": "1.0.0",
|
||||
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/machines",
|
||||
"provides": {
|
||||
"asset_type": "machine",
|
||||
"features": [
|
||||
"machine_tracking",
|
||||
"maintenance_scheduling",
|
||||
"vendor_management",
|
||||
"model_catalog"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"enable_maintenance_alerts": true,
|
||||
"maintenance_alert_days": 30
|
||||
}
|
||||
}
|
||||
14
plugins/machines/migrations/env.py
Normal file
14
plugins/machines/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the machines plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which
|
||||
filters the metadata to this plugin's tables and drives Alembic against the
|
||||
per-plugin version table alembic_version_machines. See ADR-008 for the
|
||||
ownership cutover between the core chain and per-plugin chains.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'machines'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/machines/migrations/script.py.mako
Normal file
24
plugins/machines/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
32
plugins/machines/migrations/versions/0001_machines_anchor.py
Normal file
32
plugins/machines/migrations/versions/0001_machines_anchor.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""machines plugin anchor (ownership cutover; authored as equipment).
|
||||
|
||||
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
|
||||
already created every table this plugin owns at the cutover point, so there is
|
||||
nothing to build here. This revision gives the plugin's own chain a base that
|
||||
`flask plugin upgrade-all` can stamp into alembic_version_machines. From this
|
||||
anchor forward, new machines schema changes land as 000N revisions in this
|
||||
directory, never in the core chain. See ADR-008.
|
||||
|
||||
The revision id keeps its original 'equipment0001anchor' string: ids are
|
||||
arbitrary and existing installs already carry it in their version table
|
||||
(which the core chain renames to alembic_version_machines).
|
||||
"""
|
||||
from alembic import op # noqa: F401
|
||||
import sqlalchemy as sa # noqa: F401
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'equipment0001anchor'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No-op: the core chain owns every table that exists at the cutover.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No-op: this anchor never unwinds core-owned tables.
|
||||
pass
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Rename equipment tables to machines
|
||||
|
||||
Plugin half of the approved equipment -> machines rename (the core half is
|
||||
migrations/versions/7d17_machines_rename.py):
|
||||
|
||||
equipment -> machines (equipmentid -> machineid,
|
||||
equipmenttypeid -> machinetypeid)
|
||||
equipmenttypes -> machinetypes (equipmenttypeid -> machinetypeid,
|
||||
equipmenttype -> machinetype)
|
||||
|
||||
Runs on BOTH upgraded installs and fresh installs: the core baseline creates
|
||||
these tables under their old names (frozen DDL), so this revision always does
|
||||
the physical rename. Idempotent: skips when the tables already carry the new
|
||||
names (e.g. test DBs built by db.create_all() from current models). Refuses
|
||||
to run while the core machinetypes table still exists - `flask db upgrade`
|
||||
(which renames it to modeltypes) must land first.
|
||||
|
||||
Revision ID: machines0002rename
|
||||
Revises: equipment0001anchor
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'machines0002rename'
|
||||
down_revision = 'equipment0001anchor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _fk_names(insp, table, referred_table):
|
||||
# find FK constraint names on table pointing at referred_table
|
||||
return [fk['name'] for fk in insp.get_foreign_keys(table)
|
||||
if fk.get('referred_table') == referred_table and fk.get('name')]
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
tables = set(insp.get_table_names())
|
||||
|
||||
if 'equipment' not in tables and 'equipmenttypes' not in tables:
|
||||
# already renamed, or fresh schema built straight from current models
|
||||
return
|
||||
|
||||
if 'machinetypes' in tables:
|
||||
# core chain still owns the machinetypes name (not yet renamed to
|
||||
# modeltypes) - renaming equipmenttypes onto it would collide
|
||||
raise RuntimeError(
|
||||
"Core migration 7d17_machines_rename has not run: the legacy "
|
||||
"machinetypes table still exists. Run `flask db upgrade` before "
|
||||
"`flask plugin upgrade-all`."
|
||||
)
|
||||
|
||||
if bind.dialect.name == 'mysql':
|
||||
_upgrade_mysql(bind, insp)
|
||||
else:
|
||||
_upgrade_generic(insp)
|
||||
|
||||
|
||||
def _upgrade_mysql(bind, insp):
|
||||
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
|
||||
|
||||
# snapshot index names before the renames invalidate the table name
|
||||
machine_indexes = {ix['name'] for ix in insp.get_indexes('equipment')}
|
||||
|
||||
# drop the equipment -> equipmenttypes FK before renaming the parent cols
|
||||
for name in _fk_names(insp, 'equipment', 'equipmenttypes'):
|
||||
bind.exec_driver_sql(f"ALTER TABLE equipment DROP FOREIGN KEY {name}")
|
||||
|
||||
bind.exec_driver_sql("RENAME TABLE equipmenttypes TO machinetypes")
|
||||
bind.exec_driver_sql("RENAME TABLE equipment TO machines")
|
||||
|
||||
bind.exec_driver_sql(
|
||||
"ALTER TABLE machinetypes "
|
||||
"CHANGE equipmenttypeid machinetypeid INT NOT NULL AUTO_INCREMENT, "
|
||||
"CHANGE equipmenttype machinetype VARCHAR(100) NOT NULL, "
|
||||
"DROP KEY equipmenttype, "
|
||||
"ADD UNIQUE KEY machinetype (machinetype)"
|
||||
)
|
||||
|
||||
parts = [
|
||||
"CHANGE equipmentid machineid INT NOT NULL AUTO_INCREMENT",
|
||||
"CHANGE equipmenttypeid machinetypeid INT NULL",
|
||||
]
|
||||
# rename the model-declared index names to match the Machine model
|
||||
if 'idx_equipment_type' in machine_indexes:
|
||||
parts += ["DROP KEY idx_equipment_type",
|
||||
"ADD KEY idx_machine_type (machinetypeid)"]
|
||||
if 'idx_equipment_vendor' in machine_indexes:
|
||||
parts += ["DROP KEY idx_equipment_vendor",
|
||||
"ADD KEY idx_machine_vendor (vendorid)"]
|
||||
if 'ix_equipment_assetid' in machine_indexes:
|
||||
parts += ["DROP KEY ix_equipment_assetid",
|
||||
"ADD UNIQUE KEY ix_machines_assetid (assetid)"]
|
||||
parts += [
|
||||
"ADD CONSTRAINT fk_machines_machinetypeid "
|
||||
"FOREIGN KEY (machinetypeid) REFERENCES machinetypes (machinetypeid)"
|
||||
]
|
||||
bind.exec_driver_sql("ALTER TABLE machines " + ", ".join(parts))
|
||||
|
||||
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")
|
||||
|
||||
|
||||
def _upgrade_generic(insp):
|
||||
# SQLite (and anything else): batch rename via ALTER ... RENAME
|
||||
op.rename_table('equipmenttypes', 'machinetypes')
|
||||
op.rename_table('equipment', 'machines')
|
||||
with op.batch_alter_table('machinetypes') as batch_op:
|
||||
batch_op.alter_column('equipmenttypeid', new_column_name='machinetypeid',
|
||||
existing_type=sa.Integer(), existing_nullable=False)
|
||||
batch_op.alter_column('equipmenttype', new_column_name='machinetype',
|
||||
existing_type=sa.String(100),
|
||||
existing_nullable=False)
|
||||
with op.batch_alter_table('machines') as batch_op:
|
||||
batch_op.alter_column('equipmentid', new_column_name='machineid',
|
||||
existing_type=sa.Integer(), existing_nullable=False)
|
||||
batch_op.alter_column('equipmenttypeid', new_column_name='machinetypeid',
|
||||
existing_type=sa.Integer(), existing_nullable=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
tables = set(insp.get_table_names())
|
||||
|
||||
if 'machines' not in tables and 'machinetypes' not in tables:
|
||||
return
|
||||
|
||||
if bind.dialect.name != 'mysql':
|
||||
raise NotImplementedError(
|
||||
"downgrade implemented for MySQL only (dev/prod dialect)")
|
||||
|
||||
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
|
||||
machine_indexes = {ix['name'] for ix in insp.get_indexes('machines')}
|
||||
for name in _fk_names(insp, 'machines', 'machinetypes'):
|
||||
bind.exec_driver_sql(f"ALTER TABLE machines DROP FOREIGN KEY {name}")
|
||||
|
||||
bind.exec_driver_sql("RENAME TABLE machinetypes TO equipmenttypes")
|
||||
bind.exec_driver_sql("RENAME TABLE machines TO equipment")
|
||||
|
||||
bind.exec_driver_sql(
|
||||
"ALTER TABLE equipmenttypes "
|
||||
"CHANGE machinetypeid equipmenttypeid INT NOT NULL AUTO_INCREMENT, "
|
||||
"CHANGE machinetype equipmenttype VARCHAR(100) NOT NULL, "
|
||||
"DROP KEY machinetype, "
|
||||
"ADD UNIQUE KEY equipmenttype (equipmenttype)"
|
||||
)
|
||||
|
||||
parts = [
|
||||
"CHANGE machineid equipmentid INT NOT NULL AUTO_INCREMENT",
|
||||
"CHANGE machinetypeid equipmenttypeid INT NULL",
|
||||
]
|
||||
if 'idx_machine_type' in machine_indexes:
|
||||
parts += ["DROP KEY idx_machine_type",
|
||||
"ADD KEY idx_equipment_type (equipmenttypeid)"]
|
||||
if 'idx_machine_vendor' in machine_indexes:
|
||||
parts += ["DROP KEY idx_machine_vendor",
|
||||
"ADD KEY idx_equipment_vendor (vendorid)"]
|
||||
if 'ix_machines_assetid' in machine_indexes:
|
||||
parts += ["DROP KEY ix_machines_assetid",
|
||||
"ADD UNIQUE KEY ix_equipment_assetid (assetid)"]
|
||||
parts += [
|
||||
"ADD CONSTRAINT equipment_ibfk_2 "
|
||||
"FOREIGN KEY (equipmenttypeid) REFERENCES equipmenttypes (equipmenttypeid)"
|
||||
]
|
||||
bind.exec_driver_sql("ALTER TABLE equipment " + ", ".join(parts))
|
||||
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")
|
||||
8
plugins/machines/models/__init__.py
Normal file
8
plugins/machines/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Machines plugin models."""
|
||||
|
||||
from .machine import Machine, MachineType
|
||||
|
||||
__all__ = [
|
||||
'Machine',
|
||||
'MachineType',
|
||||
]
|
||||
133
plugins/machines/models/machine.py
Normal file
133
plugins/machines/models/machine.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Machines plugin models."""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class MachineType(BaseModel):
|
||||
"""
|
||||
Machine type classification.
|
||||
|
||||
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc.
|
||||
"""
|
||||
__tablename__ = 'machinetypes'
|
||||
|
||||
machinetypeid = db.Column(db.Integer, primary_key=True)
|
||||
machinetype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MachineType {self.machinetype}>"
|
||||
|
||||
|
||||
class Machine(BaseModel):
|
||||
"""
|
||||
Machine-specific extension data.
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores machine-specific fields like type, model, vendor, etc.
|
||||
"""
|
||||
__tablename__ = 'machines'
|
||||
|
||||
machineid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Machine classification
|
||||
machinetypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('machinetypes.machinetypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Vendor and model
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Machine-specific fields
|
||||
requiresmanualconfig = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Multi-PC machine needs manual configuration'
|
||||
)
|
||||
islocationonly = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Virtual location marker (not actual machine)'
|
||||
)
|
||||
|
||||
# Maintenance tracking
|
||||
lastmaintenancedate = db.Column(db.DateTime, nullable=True)
|
||||
nextmaintenancedate = db.Column(db.DateTime, nullable=True)
|
||||
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Controller info (for CNC machines)
|
||||
controllervendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True,
|
||||
comment='Controller vendor (e.g., FANUC)'
|
||||
)
|
||||
controllermodelid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('models.modelnumberid'),
|
||||
nullable=True,
|
||||
comment='Controller model (e.g., 31B)'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('machine', uselist=False, lazy='joined')
|
||||
)
|
||||
machinetype = db.relationship('MachineType', backref='machines')
|
||||
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='machine_items')
|
||||
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='machine_items')
|
||||
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='machine_controllers')
|
||||
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='machine_controller_models')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_machine_type', 'machinetypeid'),
|
||||
db.Index('idx_machine_vendor', 'vendorid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Machine {self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.machinetype:
|
||||
result['machinetypename'] = self.machinetype.machinetype
|
||||
if self.vendor:
|
||||
result['vendorname'] = self.vendor.vendor
|
||||
if self.model:
|
||||
result['modelname'] = self.model.modelnumber
|
||||
if self.model.imageurl:
|
||||
result['imageurl'] = self.model.imageurl
|
||||
|
||||
# Add controller info
|
||||
if self.controllervendor:
|
||||
result['controllervendorname'] = self.controllervendor.vendor
|
||||
if self.controllermodel:
|
||||
result['controllermodelname'] = self.controllermodel.modelnumber
|
||||
|
||||
return result
|
||||
228
plugins/machines/plugin.py
Normal file
228
plugins/machines/plugin.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Machines plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType, AssetStatus
|
||||
|
||||
from .models import Machine, MachineType
|
||||
from .api import machines_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MachinesPlugin(BasePlugin):
|
||||
"""
|
||||
Machines plugin - manages manufacturing machine assets.
|
||||
|
||||
Machines include CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
|
||||
Uses the new Asset architecture with Machine extension table.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
if manifestpath.exists():
|
||||
with open(manifestpath, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'machines'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Machine management for manufacturing assets'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/machines'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return machines_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Machine, MachineType]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Machines plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_asset_statuses()
|
||||
self._ensure_machine_types()
|
||||
logger.info("Machines plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure machine asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='machine').first()
|
||||
if not existing:
|
||||
at = AssetType(
|
||||
assettype='machine',
|
||||
pluginname='machines',
|
||||
tablename='machines',
|
||||
description='Manufacturing machines (CNCs, CMMs, lathes, etc.)',
|
||||
icon='cog'
|
||||
)
|
||||
db.session.add(at)
|
||||
logger.debug("Created asset type: machine")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_asset_statuses(self) -> None:
|
||||
"""Ensure standard asset statuses exist."""
|
||||
statuses = [
|
||||
('In Use', 'Asset is currently in use', '#28a745'),
|
||||
('Spare', 'Spare/backup asset', '#17a2b8'),
|
||||
('Retired', 'Asset has been retired', '#6c757d'),
|
||||
('Maintenance', 'Asset is under maintenance', '#ffc107'),
|
||||
('Decommissioned', 'Asset has been decommissioned', '#dc3545'),
|
||||
]
|
||||
|
||||
for name, description, color in statuses:
|
||||
existing = AssetStatus.query.filter_by(status=name).first()
|
||||
if not existing:
|
||||
s = AssetStatus(
|
||||
status=name,
|
||||
description=description,
|
||||
color=color
|
||||
)
|
||||
db.session.add(s)
|
||||
logger.debug(f"Created asset status: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_machine_types(self) -> None:
|
||||
"""Ensure basic machine types exist."""
|
||||
machine_types = [
|
||||
('CNC', 'Computer Numerical Control machine', 'cnc'),
|
||||
('CMM', 'Coordinate Measuring Machine', 'cmm'),
|
||||
('Lathe', 'Lathe machine', 'lathe'),
|
||||
('Grinder', 'Grinding machine', 'grinder'),
|
||||
('EDM', 'Electrical Discharge Machine', 'edm'),
|
||||
('Part Marker', 'Part marking/engraving machine', 'marker'),
|
||||
('Mill', 'Milling machine', 'mill'),
|
||||
('Press', 'Press machine', 'press'),
|
||||
('Robot', 'Industrial robot', 'robot'),
|
||||
('Other', 'Other machine type', 'cog'),
|
||||
]
|
||||
|
||||
for name, description, icon in machine_types:
|
||||
existing = MachineType.query.filter_by(machinetype=name).first()
|
||||
if not existing:
|
||||
mt = MachineType(
|
||||
machinetype=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(mt)
|
||||
logger.debug(f"Created machine type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Machines plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('machines')
|
||||
def machinescli():
|
||||
"""Machines plugin commands."""
|
||||
pass
|
||||
|
||||
@machinescli.command('list-types')
|
||||
def list_types():
|
||||
"""List all machine types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = MachineType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No machine types found.')
|
||||
return
|
||||
|
||||
click.echo('Machine Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.machinetypeid}] {t.machinetype}")
|
||||
|
||||
@machinescli.command('stats')
|
||||
def stats():
|
||||
"""Show machine statistics."""
|
||||
from flask import current_app
|
||||
from shopdb.api import Asset
|
||||
|
||||
with current_app.app_context():
|
||||
total = db.session.query(Machine).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
).count()
|
||||
|
||||
click.echo(f"Total active machines: {total}")
|
||||
|
||||
# By type
|
||||
by_type = db.session.query(
|
||||
MachineType.machinetype,
|
||||
db.func.count(Machine.machineid)
|
||||
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
|
||||
).join(Asset, Asset.assetid == Machine.assetid
|
||||
).filter(Asset.isactive == True
|
||||
).group_by(MachineType.machinetype
|
||||
).all()
|
||||
|
||||
if by_type:
|
||||
click.echo("\nBy Type:")
|
||||
for t, c in by_type:
|
||||
click.echo(f" {t}: {c}")
|
||||
|
||||
return [machinescli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Machine Status',
|
||||
'component': 'MachineStatusWidget',
|
||||
'endpoint': '/api/machines/dashboard/summary',
|
||||
'size': 'medium',
|
||||
'position': 5,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Machines',
|
||||
'icon': 'cog',
|
||||
'route': '/machines',
|
||||
'position': 10,
|
||||
},
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""Return the RBAC permissions this plugin owns."""
|
||||
return [
|
||||
('machines.view', 'View machines', 'machines'),
|
||||
('machines.create', 'Create machines', 'machines'),
|
||||
('machines.edit', 'Edit machines', 'machines'),
|
||||
('machines.delete', 'Delete machines', 'machines'),
|
||||
]
|
||||
5
plugins/measuringtools/__init__.py
Normal file
5
plugins/measuringtools/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Measuring-tools plugin package."""
|
||||
|
||||
from .plugin import MeasuringToolsPlugin
|
||||
|
||||
__all__ = ['MeasuringToolsPlugin']
|
||||
5
plugins/measuringtools/api/__init__.py
Normal file
5
plugins/measuringtools/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Measuring-tools plugin API blueprint."""
|
||||
|
||||
from .routes import measuringtools_bp
|
||||
|
||||
__all__ = ['measuringtools_bp']
|
||||
395
plugins/measuringtools/api/routes.py
Normal file
395
plugins/measuringtools/api/routes.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""Measuring-tools plugin API.
|
||||
|
||||
Two resources: the measuringtooltypes lookup (CRUD with an in-use delete guard)
|
||||
and the measuring tools themselves (an Asset core row plus a measuringtools
|
||||
extension row, written in one payload). Reads are jwt-optional per the app
|
||||
convention; writes require the measuringtools.* permissions.
|
||||
|
||||
Calibration status is derived at read time in the model (see
|
||||
models.derive_status), never stored, so the report and list badges are always
|
||||
current.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
db, Asset, AssetType, AuditLog,
|
||||
success_response, error_response, paginated_response, ErrorCodes,
|
||||
get_pagination_params, paginate_query,
|
||||
require_permission, apply_import_timestamps,
|
||||
)
|
||||
|
||||
from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COLORS
|
||||
|
||||
measuringtools_bp = Blueprint('measuringtools', __name__)
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _merged(tool, today=None):
|
||||
"""Asset core dict with the measuringtools extension nested under 'measuringtool'."""
|
||||
result = tool.asset.to_dict() if tool.asset else {}
|
||||
result['measuringtool'] = tool.to_dict(today)
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Measuring-tool types
|
||||
# =============================================================================
|
||||
|
||||
@measuringtools_bp.route('/types', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_types():
|
||||
"""List measuring-tool types."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
query = MeasuringToolType.query
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(MeasuringToolType.isactive == True)
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(MeasuringToolType.name.ilike(f'%{search}%'))
|
||||
query = query.order_by(MeasuringToolType.name)
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
return paginated_response([t.to_dict() for t in items], page, per_page, total)
|
||||
|
||||
|
||||
@measuringtools_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_type(type_id: int):
|
||||
"""Get one measuring-tool type."""
|
||||
tool_type = db.session.get(MeasuringToolType, type_id)
|
||||
if not tool_type:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Measuring-tool type {type_id} not found', http_code=404)
|
||||
return success_response(tool_type.to_dict())
|
||||
|
||||
|
||||
@measuringtools_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.create')
|
||||
def create_type():
|
||||
"""Create a measuring-tool type (reactivates a soft-deleted same-named one)."""
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
||||
|
||||
existing = MeasuringToolType.query.filter_by(name=name).first()
|
||||
if existing:
|
||||
if not existing.isactive:
|
||||
existing.isactive = True
|
||||
for key in ('description', '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 type')
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Measuring-tool type '{name}' already exists", http_code=409)
|
||||
|
||||
tool_type = MeasuringToolType(
|
||||
name=name,
|
||||
description=data.get('description'),
|
||||
color=data.get('color'),
|
||||
)
|
||||
db.session.add(tool_type)
|
||||
db.session.commit()
|
||||
return success_response(tool_type.to_dict(), message='Measuring-tool type created',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@measuringtools_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.edit')
|
||||
def update_type(type_id: int):
|
||||
"""Update a measuring-tool type."""
|
||||
tool_type = db.session.get(MeasuringToolType, type_id)
|
||||
if not tool_type:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Measuring-tool type {type_id} not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
if 'name' in data and data['name'] != tool_type.name:
|
||||
if MeasuringToolType.query.filter_by(name=data['name']).first():
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Measuring-tool type '{data['name']}' already exists",
|
||||
http_code=409)
|
||||
for key in ('name', 'description', 'color', 'isactive'):
|
||||
if key in data:
|
||||
setattr(tool_type, key, data[key])
|
||||
db.session.commit()
|
||||
return success_response(tool_type.to_dict(), message='Measuring-tool type updated')
|
||||
|
||||
|
||||
@measuringtools_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.delete')
|
||||
def delete_type(type_id: int):
|
||||
"""Delete a measuring-tool type. Refused if any tool still uses it."""
|
||||
tool_type = db.session.get(MeasuringToolType, type_id)
|
||||
if not tool_type:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Measuring-tool type {type_id} not found', http_code=404)
|
||||
inuse = MeasuringTool.query.filter_by(measuringtooltypeid=type_id).count()
|
||||
if inuse:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f'Cannot delete: {inuse} tool(s) still use this type',
|
||||
http_code=409)
|
||||
db.session.delete(tool_type)
|
||||
db.session.commit()
|
||||
return success_response(message='Measuring-tool type deleted')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Measuring tools
|
||||
# =============================================================================
|
||||
|
||||
@measuringtools_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_tools():
|
||||
"""List measuring tools with filters + pagination.
|
||||
|
||||
Query params: typeid, locationid, statusid, calibrationstatus, search,
|
||||
active, page, perpage.
|
||||
"""
|
||||
page, per_page = get_pagination_params(request)
|
||||
query = db.session.query(MeasuringTool).join(Asset)
|
||||
|
||||
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)
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(db.or_(
|
||||
Asset.assetnumber.ilike(f'%{search}%'),
|
||||
Asset.name.ilike(f'%{search}%'),
|
||||
Asset.serialnumber.ilike(f'%{search}%'),
|
||||
))
|
||||
if type_id := request.args.get('typeid', type=int):
|
||||
query = query.filter(MeasuringTool.measuringtooltypeid == type_id)
|
||||
if location_id := request.args.get('locationid', type=int):
|
||||
query = query.filter(Asset.locationid == location_id)
|
||||
if status_id := request.args.get('statusid', type=int):
|
||||
query = query.filter(Asset.statusid == status_id)
|
||||
|
||||
query = query.order_by(Asset.assetnumber)
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
|
||||
today = date.today()
|
||||
data = [_merged(tool, today) for tool in items]
|
||||
|
||||
# Calibration status is derived, so it cannot be a SQL filter - apply it
|
||||
# to the built rows. Filtering post-pagination is acceptable here because
|
||||
# the dataset is a gage lab's worth of tools, not a fleet.
|
||||
calibrationstatus = request.args.get('calibrationstatus')
|
||||
if calibrationstatus:
|
||||
data = [d for d in data
|
||||
if d['measuringtool']['calibrationstatus'] == calibrationstatus]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@measuringtools_bp.route('/<int:tool_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_tool(tool_id: int):
|
||||
"""Get one measuring tool (asset core merged with the extension)."""
|
||||
tool = db.session.get(MeasuringTool, tool_id)
|
||||
if not tool:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Measuring tool {tool_id} not found', http_code=404)
|
||||
return success_response(_merged(tool))
|
||||
|
||||
|
||||
@measuringtools_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_tool_by_asset(asset_id: int):
|
||||
"""Get a measuring tool by its core asset id."""
|
||||
tool = MeasuringTool.query.filter_by(assetid=asset_id).first()
|
||||
if not tool:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Measuring tool for asset {asset_id} not found', http_code=404)
|
||||
return success_response(_merged(tool))
|
||||
|
||||
|
||||
def _asset_type_id():
|
||||
"""Resolve the seeded 'measuring_tool' asset-type id, or None."""
|
||||
asset_type = AssetType.query.filter_by(assettype='measuring_tool').first()
|
||||
return asset_type.assettypeid if asset_type else None
|
||||
|
||||
|
||||
@measuringtools_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.create')
|
||||
def create_tool():
|
||||
"""Create a measuring tool: one Asset core row + one extension row."""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('assetnumber'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
|
||||
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)
|
||||
|
||||
assettypeid = _asset_type_id()
|
||||
if not assettypeid:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'measuring_tool asset type not found. Plugin may not be '
|
||||
'properly installed.', http_code=500)
|
||||
|
||||
asset = Asset(
|
||||
assetnumber=data['assetnumber'],
|
||||
name=data.get('name'),
|
||||
gaugelabreference=data.get('gaugelabreference'),
|
||||
maintenancereference=data.get('maintenancereference'),
|
||||
serialnumber=data.get('serialnumber'),
|
||||
assettypeid=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() # assign assetid
|
||||
|
||||
tool = MeasuringTool(
|
||||
assetid=asset.assetid,
|
||||
measuringtooltypeid=data.get('measuringtooltypeid'),
|
||||
calibrationintervaldays=data.get('calibrationintervaldays'),
|
||||
lastcalibrationdate=_parse_date(data.get('lastcalibrationdate')),
|
||||
nextcalibrationdate=_parse_date(data.get('nextcalibrationdate')),
|
||||
calibrationprovider=(data.get('calibrationprovider') or '').strip() or None,
|
||||
notes=(data.get('notes') or '').strip() or None,
|
||||
)
|
||||
db.session.add(tool)
|
||||
db.session.flush()
|
||||
|
||||
apply_import_timestamps(asset, data)
|
||||
AuditLog.log('created', 'MeasuringTool', entityid=tool.measuringtoolid,
|
||||
entityname=asset.assetnumber)
|
||||
db.session.commit()
|
||||
return success_response(_merged(tool), message='Measuring tool created', http_code=201)
|
||||
|
||||
|
||||
# Asset core fields writable through this plugin's write path.
|
||||
_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference',
|
||||
'maintenancereference', 'serialnumber',
|
||||
'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy',
|
||||
'notes', 'isactive')
|
||||
# Extension fields with plain assignment (dates handled separately).
|
||||
_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays',
|
||||
'calibrationprovider', 'notes')
|
||||
|
||||
|
||||
@measuringtools_bp.route('/<int:tool_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.edit')
|
||||
def update_tool(tool_id: int):
|
||||
"""Update a measuring tool (asset core fields + extension in one payload)."""
|
||||
tool = db.session.get(MeasuringTool, tool_id)
|
||||
if not tool:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Measuring tool {tool_id} not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
asset = tool.asset
|
||||
|
||||
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)
|
||||
|
||||
changes = {}
|
||||
for key in _ASSET_FIELDS:
|
||||
if key in data:
|
||||
if getattr(asset, key) != data[key]:
|
||||
changes[key] = {'old': getattr(asset, key), 'new': data[key]}
|
||||
setattr(asset, key, data[key])
|
||||
for key in _TOOL_FIELDS:
|
||||
if key in data:
|
||||
if getattr(tool, key) != data[key]:
|
||||
changes[key] = {'old': getattr(tool, key), 'new': data[key]}
|
||||
setattr(tool, key, data[key])
|
||||
for key in ('lastcalibrationdate', 'nextcalibrationdate'):
|
||||
if key in data:
|
||||
setattr(tool, key, _parse_date(data[key]))
|
||||
|
||||
if changes:
|
||||
AuditLog.log('updated', 'MeasuringTool', entityid=tool.measuringtoolid,
|
||||
entityname=asset.assetnumber, changes=changes)
|
||||
apply_import_timestamps(asset, data)
|
||||
db.session.commit()
|
||||
return success_response(_merged(tool), message='Measuring tool updated')
|
||||
|
||||
|
||||
@measuringtools_bp.route('/<int:tool_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.delete')
|
||||
def delete_tool(tool_id: int):
|
||||
"""Soft delete a measuring tool (deactivates its asset)."""
|
||||
tool = db.session.get(MeasuringTool, tool_id)
|
||||
if not tool:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Measuring tool {tool_id} not found', http_code=404)
|
||||
tool.asset.isactive = False
|
||||
AuditLog.log('deleted', 'MeasuringTool', entityid=tool.measuringtoolid,
|
||||
entityname=tool.asset.assetnumber)
|
||||
db.session.commit()
|
||||
return success_response(message='Measuring tool deleted')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Map overlay (ADR-010 calibration-due badge)
|
||||
# =============================================================================
|
||||
|
||||
@measuringtools_bp.route('/map-overlay', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def map_overlay():
|
||||
"""Calibration-status overlay for active measuring tools.
|
||||
|
||||
The map places markers itself from the assets feed; this overlay only
|
||||
supplies the per-asset calibration decoration. Consumers join by assetid,
|
||||
so no map coordinates are returned. Status is derived at read time.
|
||||
"""
|
||||
today = date.today()
|
||||
query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True)
|
||||
data = []
|
||||
for tool in query.all():
|
||||
status = derive_status(tool.nextcalibrationdate, today)
|
||||
data.append({
|
||||
'assetid': tool.assetid,
|
||||
'calibrationstatus': status,
|
||||
'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']),
|
||||
})
|
||||
return success_response(data)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Calibration report (for the Reports hub)
|
||||
# =============================================================================
|
||||
|
||||
@measuringtools_bp.route('/report/calibration', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def calibration_report():
|
||||
"""Counts + lists bucketed by derived calibration status."""
|
||||
today = date.today()
|
||||
buckets = {'overdue': [], 'duesoon': [], 'current': [], 'unknown': []}
|
||||
query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True)
|
||||
for tool in query.all():
|
||||
buckets.setdefault(derive_status(tool.nextcalibrationdate, today), []).append(
|
||||
_merged(tool, today))
|
||||
return success_response({
|
||||
'counts': {key: len(value) for key, value in buckets.items()},
|
||||
'buckets': buckets,
|
||||
'statuscolors': STATUS_COLORS,
|
||||
})
|
||||
14
plugins/measuringtools/manifest.json
Normal file
14
plugins/measuringtools/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "measuringtools",
|
||||
"display_name": "Measuring Tools",
|
||||
"version": "1.0.0",
|
||||
"description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.6.0,<1.0.0",
|
||||
"api_prefix": "/api/measuringtools",
|
||||
"default_enabled": false,
|
||||
"provides": {
|
||||
"features": ["measuring-tools", "calibration-tracking"]
|
||||
}
|
||||
}
|
||||
16
plugins/measuringtools/migrations/env.py
Normal file
16
plugins/measuringtools/migrations/env.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Alembic environment for the measuring-tools plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
|
||||
the metadata to this plugin's tables and drives Alembic against the per-plugin
|
||||
version table alembic_version_measuringtools. See ADR-008 for the ownership
|
||||
model. Unlike the ten cutover plugins whose 0001 is a no-op anchor, this plugin
|
||||
is NEW: its 0001 baseline really CREATES its tables, because the core chain
|
||||
never built them.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'measuringtools'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/measuringtools/migrations/script.py.mako
Normal file
24
plugins/measuringtools/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""measuringtools plugin baseline (real create).
|
||||
|
||||
This plugin was built AFTER the ADR-008 ownership cutover, so unlike the ten
|
||||
bundled plugins whose 0001 is a stamp-only no-op anchor, this baseline actually
|
||||
CREATES the plugin's tables. The core Alembic chain never knew about
|
||||
measuringtooltypes / measuringtools, so this per-plugin chain is their sole
|
||||
authoritative creator. It runs from `flask plugin install measuringtools`
|
||||
(and `flask plugin upgrade-all`) after `flask db upgrade` builds the core schema.
|
||||
|
||||
Note on create_plugin_tables: the shared helper in
|
||||
shopdb.plugins.alembic_template builds a per-plugin MetaData filtered to only the
|
||||
plugin's own tables, which means a foreign key to a core table (assets) cannot
|
||||
be resolved at CreateTable-compile time (NoReferencedTableError). Since these
|
||||
tables reference assets.assetid, the baseline emits explicit Alembic ops (the
|
||||
same shape Alembic autogenerate produces) instead. Tables inherit the
|
||||
connection's default charset, so on a utf8mb4 database they are utf8mb4, matching
|
||||
how the core chain creates its tables.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'measuringtools0001baseline'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'measuringtooltypes',
|
||||
sa.Column('measuringtooltypeid', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.String(length=20), nullable=True),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('measuringtooltypeid'),
|
||||
sa.UniqueConstraint('name'),
|
||||
)
|
||||
op.create_table(
|
||||
'measuringtools',
|
||||
sa.Column('measuringtoolid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('measuringtooltypeid', sa.Integer(), nullable=True),
|
||||
sa.Column('calibrationintervaldays', sa.Integer(), nullable=True),
|
||||
sa.Column('lastcalibrationdate', sa.Date(), nullable=True),
|
||||
sa.Column('nextcalibrationdate', sa.Date(), nullable=True),
|
||||
sa.Column('calibrationprovider', sa.String(length=150), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['measuringtooltypeid'],
|
||||
['measuringtooltypes.measuringtooltypeid']),
|
||||
sa.PrimaryKeyConstraint('measuringtoolid'),
|
||||
sa.UniqueConstraint('assetid'),
|
||||
)
|
||||
op.create_index('idx_measuringtool_type', 'measuringtools',
|
||||
['measuringtooltypeid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('idx_measuringtool_type', table_name='measuringtools')
|
||||
op.drop_table('measuringtools')
|
||||
op.drop_table('measuringtooltypes')
|
||||
17
plugins/measuringtools/models/__init__.py
Normal file
17
plugins/measuringtools/models/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Measuring-tools plugin models."""
|
||||
|
||||
from .measuringtool import (
|
||||
MeasuringTool,
|
||||
MeasuringToolType,
|
||||
derive_status,
|
||||
STATUS_COLORS,
|
||||
DUESOON_WINDOW_DAYS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'MeasuringTool',
|
||||
'MeasuringToolType',
|
||||
'derive_status',
|
||||
'STATUS_COLORS',
|
||||
'DUESOON_WINDOW_DAYS',
|
||||
]
|
||||
133
plugins/measuringtools/models/measuringtool.py
Normal file
133
plugins/measuringtools/models/measuringtool.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Measuring-tools models.
|
||||
|
||||
Measuring tools are gage-lab instruments (calipers, micrometers, thread gages,
|
||||
bore gages, height gages, Genspect heads, ...) that measure parts, as opposed
|
||||
to machines that make parts (see ADR-005). Each tool is a core Asset plus a
|
||||
one-to-one measuringtools extension row.
|
||||
|
||||
The lifecycle a measuring tool cares about is CALIBRATION, not maintenance:
|
||||
an interval, a last date, and a next date. Calibration STATUS is DERIVED from
|
||||
nextcalibrationdate at read time (see derive_status), never stored, so it is
|
||||
always current no matter how long since the last write. This mirrors the
|
||||
warranty plugin's derive-at-read pattern.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
# Window before nextcalibrationdate where a tool counts as "due soon".
|
||||
DUESOON_WINDOW_DAYS = 30
|
||||
|
||||
# Derived status -> display color (hex). Reused by the frontend status badge.
|
||||
STATUS_COLORS = {
|
||||
'overdue': '#F44336',
|
||||
'duesoon': '#FF9800',
|
||||
'current': '#4CAF50',
|
||||
'unknown': '#9E9E9E',
|
||||
}
|
||||
|
||||
|
||||
def derive_status(nextcalibrationdate, today=None):
|
||||
"""Calibration status from a next-calibration date. Never stored.
|
||||
|
||||
overdue - the next date is in the past
|
||||
duesoon - the next date is within DUESOON_WINDOW_DAYS from today
|
||||
current - the next date is further out than the window
|
||||
unknown - no next date recorded
|
||||
"""
|
||||
if not nextcalibrationdate:
|
||||
return 'unknown'
|
||||
today = today or date.today()
|
||||
if nextcalibrationdate < today:
|
||||
return 'overdue'
|
||||
if nextcalibrationdate <= today + timedelta(days=DUESOON_WINDOW_DAYS):
|
||||
return 'duesoon'
|
||||
return 'current'
|
||||
|
||||
|
||||
class MeasuringToolType(BaseModel):
|
||||
"""Measuring-tool classification (Caliper, Micrometer, Thread Gage, ...).
|
||||
|
||||
Site-managed lookup with a display color for badges and map markers,
|
||||
the same shape as the machine/computer/printer type tables.
|
||||
"""
|
||||
__tablename__ = 'measuringtooltypes'
|
||||
|
||||
measuringtooltypeid = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MeasuringToolType {self.name}>"
|
||||
|
||||
|
||||
class MeasuringTool(BaseModel):
|
||||
"""Measuring-tool extension data, one-to-one with a core Asset.
|
||||
|
||||
Identity lives on the Asset (assetnumber = gage tag, serialnumber = vendor
|
||||
serial, gaugelabreference identifier). This row carries only the metrology
|
||||
domain fields: the tool type and the calibration lifecycle.
|
||||
"""
|
||||
__tablename__ = 'measuringtools'
|
||||
|
||||
measuringtoolid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to the core asset (one extension row per asset).
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
measuringtooltypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('measuringtooltypes.measuringtooltypeid'),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Calibration lifecycle. Status is DERIVED from nextcalibrationdate, so
|
||||
# none of these columns store a status.
|
||||
calibrationintervaldays = db.Column(db.Integer, nullable=True)
|
||||
lastcalibrationdate = db.Column(db.Date, nullable=True)
|
||||
nextcalibrationdate = db.Column(db.Date, nullable=True)
|
||||
calibrationprovider = db.Column(db.String(150), nullable=True)
|
||||
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('measuringtool', uselist=False, lazy='joined'),
|
||||
)
|
||||
measuringtooltype = db.relationship('MeasuringToolType', backref='tools')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_measuringtool_type', 'measuringtooltypeid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MeasuringTool {self.assetid}>"
|
||||
|
||||
def calibrationstatus(self, today=None):
|
||||
return derive_status(self.nextcalibrationdate, today)
|
||||
|
||||
def to_dict(self, today=None):
|
||||
"""Extension dict with the type name and derived calibration status."""
|
||||
result = super().to_dict()
|
||||
# BaseModel.to_dict only isoformats datetime, not plain date columns.
|
||||
# Emit calibration dates as 'YYYY-MM-DD' so the frontend parses them
|
||||
# the same way it parses warranty dates.
|
||||
for field in ('lastcalibrationdate', 'nextcalibrationdate'):
|
||||
value = getattr(self, field)
|
||||
result[field] = value.isoformat() if value else None
|
||||
if self.measuringtooltype:
|
||||
result['measuringtooltypename'] = self.measuringtooltype.name
|
||||
result['measuringtooltypecolor'] = self.measuringtooltype.color
|
||||
status = self.calibrationstatus(today)
|
||||
result['calibrationstatus'] = status
|
||||
result['calibrationstatuscolor'] = STATUS_COLORS.get(
|
||||
status, STATUS_COLORS['unknown'])
|
||||
return result
|
||||
179
plugins/measuringtools/plugin.py
Normal file
179
plugins/measuringtools/plugin.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Measuring-tools plugin main class.
|
||||
|
||||
The first plugin built on the matured framework scaffold (ADR-005). It owns two
|
||||
tables (measuringtooltypes, measuringtools), a blueprint under /api/measuringtools,
|
||||
a sidebar entry, and a calibration report card. It seeds its own asset type and
|
||||
a set of starter tool types on install.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .api import measuringtools_bp
|
||||
from .models import MeasuringTool, MeasuringToolType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Starter tool types seeded on install: (name, description, color).
|
||||
# Colors are drawn from the shared frontend PALETTE (utils/colorStyle.js).
|
||||
STARTER_TYPES = [
|
||||
('Caliper', 'Vernier / digital caliper', '#14abef'),
|
||||
('Micrometer', 'Outside / inside / depth micrometer', '#2dce89'),
|
||||
('Thread Gage', 'Go / no-go thread gage', '#fb6340'),
|
||||
('Bore Gage', 'Bore / hole diameter gage', '#7934f3'),
|
||||
('Height Gage', 'Height / vertical measuring gage', '#ffc107'),
|
||||
('Indicator', 'Dial / test indicator', '#11cdef'),
|
||||
('Gage Block Set', 'Reference gage block set', '#e83e8c'),
|
||||
('Other', 'Other measuring tool', '#6c757d'),
|
||||
]
|
||||
|
||||
|
||||
class MeasuringToolsPlugin(BasePlugin):
|
||||
"""Metrology and inspection instruments (calibration lifecycle)."""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'measuringtools'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description', 'Metrology and inspection instruments'),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.6.0,<1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/measuringtools'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return measuringtools_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [MeasuringTool, MeasuringToolType]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
return [
|
||||
{
|
||||
'name': 'Measuring Tools',
|
||||
'icon': 'ruler',
|
||||
'route': '/measuringtools',
|
||||
'position': 22,
|
||||
},
|
||||
]
|
||||
|
||||
def get_reports(self) -> List[Dict]:
|
||||
return [
|
||||
{
|
||||
'id': 'calibration',
|
||||
'name': 'Calibration Due',
|
||||
'description': 'Measuring tools bucketed by calibration status',
|
||||
'category': 'compliance',
|
||||
'route': '/reports/calibration',
|
||||
},
|
||||
]
|
||||
|
||||
def get_config_schema(self) -> List[Dict]:
|
||||
# No external credentials or endpoints: calibration is tracked by hand,
|
||||
# so the setup wizard shows nothing to configure. Documented in the
|
||||
# plugin guide as the intentional empty-schema case.
|
||||
return []
|
||||
|
||||
def get_settings_cards(self) -> List[Dict]:
|
||||
# ADR-010 pilot. Contributes the Measuring Tools settings card that used
|
||||
# to be hardcoded in the core settingsNav.js catalog.
|
||||
return [
|
||||
{
|
||||
'group': 'Measuring Tools',
|
||||
'to': '/settings/measuringtooltypes',
|
||||
'icon': 'ruler',
|
||||
'title': 'Measuring Tool Types',
|
||||
'description': 'Manage measuring-tool subtypes (caliper, '
|
||||
'micrometer, thread gage...) + map colors',
|
||||
'position': 22,
|
||||
},
|
||||
]
|
||||
|
||||
def get_asset_presentation(self) -> List[Dict]:
|
||||
# ADR-010 pilot. Tells core how to render + link the measuring_tool
|
||||
# asset type in global-search rows and cross-links.
|
||||
# The consumer only substitutes {assetid} (search/cross-link rows carry
|
||||
# the core asset id, not the extension id), so link through the by-asset
|
||||
# resolver route rather than the id-keyed detail route.
|
||||
return [
|
||||
{
|
||||
'assettype': 'measuring_tool',
|
||||
'icon': 'ruler',
|
||||
'label': 'Measuring Tool',
|
||||
'route': '/measuringtools/by-asset/{assetid}',
|
||||
},
|
||||
]
|
||||
|
||||
def get_map_overlays(self) -> List[Dict]:
|
||||
# ADR-010 pilot. Declares a calibration-due badge overlay for the
|
||||
# shop-floor map; the map fetches the endpoint to decorate markers.
|
||||
return [
|
||||
{
|
||||
'id': 'calibration-due',
|
||||
'label': 'Calibration due',
|
||||
'endpoint': '/api/measuringtools/map-overlay',
|
||||
'style': 'badge',
|
||||
'legend': True,
|
||||
},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f"Measuring-tools plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_starter_types()
|
||||
db.session.commit()
|
||||
logger.info("Measuring-tools plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
existing = AssetType.query.filter_by(assettype='measuring_tool').first()
|
||||
if not existing:
|
||||
db.session.add(AssetType(
|
||||
assettype='measuring_tool',
|
||||
pluginname='measuringtools',
|
||||
tablename='measuringtools',
|
||||
description='Metrology and inspection instruments (gauges, '
|
||||
'calipers, thread gages, bore gages, ...)',
|
||||
icon='ruler',
|
||||
))
|
||||
logger.debug("Created asset type: measuring_tool")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_starter_types(self) -> None:
|
||||
for name, description, color in STARTER_TYPES:
|
||||
if not MeasuringToolType.query.filter_by(name=name).first():
|
||||
db.session.add(MeasuringToolType(
|
||||
name=name, description=description, color=color))
|
||||
logger.debug(f"Created measuring-tool type: {name}")
|
||||
db.session.commit()
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""Return the RBAC permissions this plugin owns."""
|
||||
return [
|
||||
('measuringtools.view', 'View measuring tools', 'measuringtools'),
|
||||
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
|
||||
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
|
||||
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
|
||||
]
|
||||
5
plugins/network/__init__.py
Normal file
5
plugins/network/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Network plugin for ShopDB."""
|
||||
|
||||
from .plugin import NetworkPlugin
|
||||
|
||||
__all__ = ['NetworkPlugin']
|
||||
5
plugins/network/api/__init__.py
Normal file
5
plugins/network/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Network plugin API."""
|
||||
|
||||
from .routes import network_bp
|
||||
|
||||
__all__ = ['network_bp']
|
||||
1029
plugins/network/api/routes.py
Normal file
1029
plugins/network/api/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
22
plugins/network/manifest.json
Normal file
22
plugins/network/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "network",
|
||||
"version": "1.0.0",
|
||||
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/network",
|
||||
"provides": {
|
||||
"asset_type": "network_device",
|
||||
"features": [
|
||||
"network_device_tracking",
|
||||
"port_management",
|
||||
"firmware_tracking",
|
||||
"poe_monitoring"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"enable_snmp_polling": false,
|
||||
"snmp_community": "public"
|
||||
}
|
||||
}
|
||||
14
plugins/network/migrations/env.py
Normal file
14
plugins/network/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the network plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which
|
||||
filters the metadata to this plugin's tables and drives Alembic against the
|
||||
per-plugin version table alembic_version_network. See ADR-008 for the
|
||||
ownership cutover between the core chain and per-plugin chains.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'network'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/network/migrations/script.py.mako
Normal file
24
plugins/network/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
28
plugins/network/migrations/versions/0001_network_anchor.py
Normal file
28
plugins/network/migrations/versions/0001_network_anchor.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""network plugin anchor (ownership cutover).
|
||||
|
||||
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
|
||||
already created every table this plugin owns at the cutover point, so there is
|
||||
nothing to build here. This revision gives the plugin's own chain a base that
|
||||
`flask plugin upgrade-all` can stamp into alembic_version_network. From this
|
||||
anchor forward, new network schema changes land as 000N revisions in this
|
||||
directory, never in the core chain. See ADR-008.
|
||||
"""
|
||||
from alembic import op # noqa: F401
|
||||
import sqlalchemy as sa # noqa: F401
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'network0001anchor'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No-op: the core chain owns every table that exists at the cutover.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No-op: this anchor never unwinds core-owned tables.
|
||||
pass
|
||||
11
plugins/network/models/__init__.py
Normal file
11
plugins/network/models/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Network plugin models."""
|
||||
|
||||
from .network_device import NetworkDevice, NetworkDeviceType
|
||||
from .subnet import Subnet, VLAN
|
||||
|
||||
__all__ = [
|
||||
'NetworkDevice',
|
||||
'NetworkDeviceType',
|
||||
'Subnet',
|
||||
'VLAN',
|
||||
]
|
||||
120
plugins/network/models/network_device.py
Normal file
120
plugins/network/models/network_device.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Network device plugin models."""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class NetworkDeviceType(BaseModel):
|
||||
"""
|
||||
Network device type classification.
|
||||
|
||||
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc.
|
||||
"""
|
||||
__tablename__ = 'networkdevicetypes'
|
||||
|
||||
networkdevicetypeid = db.Column(db.Integer, primary_key=True)
|
||||
networkdevicetype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NetworkDeviceType {self.networkdevicetype}>"
|
||||
|
||||
|
||||
class NetworkDevice(BaseModel):
|
||||
"""
|
||||
Network device-specific extension data.
|
||||
|
||||
Links to core Asset table via assetid.
|
||||
Stores network device-specific fields like hostname, firmware, ports, etc.
|
||||
"""
|
||||
__tablename__ = 'networkdevices'
|
||||
|
||||
networkdeviceid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Link to core asset
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Network device classification
|
||||
networkdevicetypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('networkdevicetypes.networkdevicetypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Vendor
|
||||
vendorid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vendors.vendorid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Network identity
|
||||
hostname = db.Column(
|
||||
db.String(100),
|
||||
comment='Network hostname'
|
||||
)
|
||||
|
||||
# Firmware/software version
|
||||
firmwareversion = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Physical characteristics
|
||||
portcount = db.Column(
|
||||
db.Integer,
|
||||
nullable=True,
|
||||
comment='Number of ports (for switches)'
|
||||
)
|
||||
|
||||
# Features
|
||||
ispoe = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Power over Ethernet capable'
|
||||
)
|
||||
ismanaged = db.Column(
|
||||
db.Boolean,
|
||||
default=False,
|
||||
comment='Managed device (SNMP, web interface, etc.)'
|
||||
)
|
||||
|
||||
# For IDF/closet locations
|
||||
rackunit = db.Column(
|
||||
db.String(20),
|
||||
nullable=True,
|
||||
comment='Rack unit position (e.g., U1, U5)'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship(
|
||||
'Asset',
|
||||
backref=db.backref('network_device', uselist=False, lazy='joined')
|
||||
)
|
||||
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
|
||||
vendor = db.relationship('Vendor', backref='network_devices')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_netdev_type', 'networkdevicetypeid'),
|
||||
db.Index('idx_netdev_hostname', 'hostname'),
|
||||
db.Index('idx_netdev_vendor', 'vendorid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NetworkDevice {self.hostname or self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related names."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names
|
||||
if self.networkdevicetype:
|
||||
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
|
||||
if self.vendor:
|
||||
result['vendorname'] = self.vendor.vendor
|
||||
|
||||
return result
|
||||
145
plugins/network/models/subnet.py
Normal file
145
plugins/network/models/subnet.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Subnet and VLAN models for network plugin."""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class VLAN(BaseModel):
|
||||
"""
|
||||
VLAN definition.
|
||||
|
||||
Represents a virtual LAN for network segmentation.
|
||||
"""
|
||||
__tablename__ = 'vlans'
|
||||
|
||||
vlanid = db.Column(db.Integer, primary_key=True)
|
||||
vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number')
|
||||
name = db.Column(db.String(100), nullable=False, comment='VLAN name')
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Optional classification
|
||||
vlantype = db.Column(
|
||||
db.String(50),
|
||||
nullable=True,
|
||||
comment='Type: data, voice, management, guest, etc.'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_vlan_number', 'vlannumber'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<VLAN {self.vlannumber} - {self.name}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary."""
|
||||
result = super().to_dict()
|
||||
result['subnetcount'] = self.subnets.count() if self.subnets else 0
|
||||
return result
|
||||
|
||||
|
||||
class Subnet(BaseModel):
|
||||
"""
|
||||
Subnet/IP network definition.
|
||||
|
||||
Represents an IP subnet with optional VLAN association.
|
||||
"""
|
||||
__tablename__ = 'subnets'
|
||||
|
||||
subnetid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Network definition
|
||||
cidr = db.Column(
|
||||
db.String(18),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
comment='CIDR notation (e.g., 10.1.1.0/24)'
|
||||
)
|
||||
name = db.Column(db.String(100), nullable=False, comment='Subnet name')
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Network details
|
||||
gatewayip = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Default gateway IP address'
|
||||
)
|
||||
subnetmask = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Subnet mask (e.g., 255.255.255.0)'
|
||||
)
|
||||
networkaddress = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Network address (e.g., 10.1.1.0)'
|
||||
)
|
||||
broadcastaddress = db.Column(
|
||||
db.String(15),
|
||||
nullable=True,
|
||||
comment='Broadcast address (e.g., 10.1.1.255)'
|
||||
)
|
||||
|
||||
# VLAN association
|
||||
vlanid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('vlans.vlanid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Classification
|
||||
subnettype = db.Column(
|
||||
db.String(50),
|
||||
nullable=True,
|
||||
comment='Type: production, development, management, dmz, etc.'
|
||||
)
|
||||
|
||||
# Location association
|
||||
locationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('locations.locationid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# DHCP settings
|
||||
dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet')
|
||||
dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP')
|
||||
dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP')
|
||||
|
||||
# DNS settings
|
||||
dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server')
|
||||
dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
|
||||
|
||||
# Relationships
|
||||
location = db.relationship('Location', backref='subnets')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_subnet_cidr', 'cidr'),
|
||||
db.Index('idx_subnet_vlan', 'vlanid'),
|
||||
db.Index('idx_subnet_location', 'locationid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subnet {self.cidr} - {self.name}>"
|
||||
|
||||
@property
|
||||
def vlan_number(self):
|
||||
"""Get the VLAN number."""
|
||||
return self.vlan.vlannumber if self.vlan else None
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related data."""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add VLAN info
|
||||
if self.vlan:
|
||||
result['vlannumber'] = self.vlan.vlannumber
|
||||
result['vlanname'] = self.vlan.name
|
||||
|
||||
# Add location info
|
||||
if self.location:
|
||||
result['locationname'] = self.location.locationname
|
||||
|
||||
return result
|
||||
225
plugins/network/plugin.py
Normal file
225
plugins/network/plugin.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Network plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
|
||||
from .api import network_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NetworkPlugin(BasePlugin):
|
||||
"""
|
||||
Network plugin - manages network device assets.
|
||||
|
||||
Network devices include switches, routers, access points, cameras, IDFs, etc.
|
||||
Uses the new Asset architecture with NetworkDevice extension table.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifestpath = Path(__file__).parent / 'manifest.json'
|
||||
if manifestpath.exists():
|
||||
with open(manifestpath, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'network'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Network device management for switches, APs, and cameras'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/network'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return network_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [NetworkDevice, NetworkDeviceType, Subnet, VLAN]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Network plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
self._ensure_network_device_types()
|
||||
logger.info("Network plugin installed")
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
"""Ensure network_device asset type exists."""
|
||||
existing = AssetType.query.filter_by(assettype='network_device').first()
|
||||
if not existing:
|
||||
at = AssetType(
|
||||
assettype='network_device',
|
||||
pluginname='network',
|
||||
tablename='networkdevices',
|
||||
description='Network infrastructure devices (switches, APs, cameras, etc.)',
|
||||
icon='network-wired'
|
||||
)
|
||||
db.session.add(at)
|
||||
logger.debug("Created asset type: network_device")
|
||||
db.session.commit()
|
||||
|
||||
def _ensure_network_device_types(self) -> None:
|
||||
"""Ensure basic network device types exist."""
|
||||
device_types = [
|
||||
('Switch', 'Network switch', 'network-wired'),
|
||||
('Router', 'Network router', 'router'),
|
||||
('Access Point', 'Wireless access point', 'wifi'),
|
||||
('Firewall', 'Network firewall', 'shield'),
|
||||
('Camera', 'IP camera', 'video'),
|
||||
('IDF', 'Intermediate Distribution Frame/closet', 'box'),
|
||||
('MDF', 'Main Distribution Frame', 'building'),
|
||||
('Patch Panel', 'Patch panel', 'th'),
|
||||
('UPS', 'Uninterruptible power supply', 'battery'),
|
||||
('Other', 'Other network device', 'network-wired'),
|
||||
]
|
||||
|
||||
for name, description, icon in device_types:
|
||||
existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first()
|
||||
if not existing:
|
||||
ndt = NetworkDeviceType(
|
||||
networkdevicetype=name,
|
||||
description=description,
|
||||
icon=icon
|
||||
)
|
||||
db.session.add(ndt)
|
||||
logger.debug(f"Created network device type: {name}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Network plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('network')
|
||||
def networkcli():
|
||||
"""Network plugin commands."""
|
||||
pass
|
||||
|
||||
@networkcli.command('list-types')
|
||||
def list_types():
|
||||
"""List all network device types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = NetworkDeviceType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No network device types found.')
|
||||
return
|
||||
|
||||
click.echo('Network Device Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}")
|
||||
|
||||
@networkcli.command('stats')
|
||||
def stats():
|
||||
"""Show network device statistics."""
|
||||
from flask import current_app
|
||||
from shopdb.api import Asset
|
||||
|
||||
with current_app.app_context():
|
||||
total = db.session.query(NetworkDevice).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
).count()
|
||||
|
||||
click.echo(f"Total active network devices: {total}")
|
||||
|
||||
# By type
|
||||
by_type = db.session.query(
|
||||
NetworkDeviceType.networkdevicetype,
|
||||
db.func.count(NetworkDevice.networkdeviceid)
|
||||
).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
|
||||
).join(Asset, Asset.assetid == NetworkDevice.assetid
|
||||
).filter(Asset.isactive == True
|
||||
).group_by(NetworkDeviceType.networkdevicetype
|
||||
).all()
|
||||
|
||||
if by_type:
|
||||
click.echo("\nBy Type:")
|
||||
for t, c in by_type:
|
||||
click.echo(f" {t}: {c}")
|
||||
|
||||
@networkcli.command('find')
|
||||
@click.argument('hostname')
|
||||
def find_by_hostname(hostname):
|
||||
"""Find a network device by hostname."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
netdev = NetworkDevice.query.filter(
|
||||
NetworkDevice.hostname.ilike(f'%{hostname}%')
|
||||
).first()
|
||||
|
||||
if not netdev:
|
||||
click.echo(f'No network device found matching hostname: {hostname}')
|
||||
return
|
||||
|
||||
click.echo(f'Found: {netdev.hostname}')
|
||||
click.echo(f' Asset: {netdev.asset.assetnumber}')
|
||||
click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}')
|
||||
click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}')
|
||||
click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}')
|
||||
|
||||
return [networkcli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Network Status',
|
||||
'component': 'NetworkStatusWidget',
|
||||
'endpoint': '/api/network/dashboard/summary',
|
||||
'size': 'medium',
|
||||
'position': 7,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Network',
|
||||
'icon': 'network-wired',
|
||||
'route': '/network',
|
||||
'position': 18,
|
||||
},
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""Return the RBAC permissions this plugin owns."""
|
||||
return [
|
||||
('network.view', 'View network devices', 'network'),
|
||||
('network.create', 'Create network devices', 'network'),
|
||||
('network.edit', 'Edit network devices', 'network'),
|
||||
('network.delete', 'Delete network devices', 'network'),
|
||||
]
|
||||
5
plugins/notifications/__init__.py
Normal file
5
plugins/notifications/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Notifications plugin package."""
|
||||
|
||||
from .plugin import NotificationsPlugin
|
||||
|
||||
__all__ = ['NotificationsPlugin']
|
||||
5
plugins/notifications/api/__init__.py
Normal file
5
plugins/notifications/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Notifications plugin API."""
|
||||
|
||||
from .routes import notifications_bp
|
||||
|
||||
__all__ = ['notifications_bp']
|
||||
759
plugins/notifications/api/routes.py
Normal file
759
plugins/notifications/api/routes.py
Normal file
@@ -0,0 +1,759 @@
|
||||
"""Notifications plugin API endpoints - adapted to existing schema."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import Notification, NotificationType
|
||||
|
||||
from shopdb.api import require_permission
|
||||
|
||||
notifications_bp = Blueprint('notifications', __name__)
|
||||
|
||||
# Notification types whose multi-employee cards get split into one card per
|
||||
# employee on the shopfloor dashboard. typecolor drives this (not typename), so
|
||||
# recognition, training and recertification all fan out; every other type
|
||||
# stays one card.
|
||||
SPLIT_TYPECOLORS = frozenset({'recognition', 'training', 'recertification'})
|
||||
|
||||
# How long a card stays up on the shopfloor board when the creator does not set
|
||||
# an explicit end time, by notification typecolor.
|
||||
# recognition - clears at the next 8:00 AM Eastern (daily reset)
|
||||
# recertification - stays up two weeks (employees have time to book the course)
|
||||
EASTERN = ZoneInfo('America/New_York')
|
||||
RECERTIFICATION_DAYS = 14
|
||||
|
||||
|
||||
def _next_eastern_time(after, hour, minute=0):
|
||||
"""Next hour:minute America/New_York strictly after `after` (naive UTC),
|
||||
returned as naive UTC. Uses the tz database so it is correct across EST/EDT."""
|
||||
after_east = after.replace(tzinfo=timezone.utc).astimezone(EASTERN)
|
||||
target = after_east.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
|
||||
if target <= after_east:
|
||||
target += timedelta(days=1)
|
||||
return target.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _auto_endtime(ntype, starttime):
|
||||
"""Default display window for a notification with no explicit end time, from
|
||||
the notification type's configured expiry rule:
|
||||
'dailytime' -> next expiryhour:expiryminute Eastern (daily reset)
|
||||
'duration' -> starttime + expirydays days
|
||||
'none' -> None (show indefinitely)
|
||||
Falls back to the legacy typecolor rules when the expiry columns are unset,
|
||||
so it is safe before/after the 7d03 migration."""
|
||||
mode = getattr(ntype, 'expirymode', None) or 'none'
|
||||
if mode == 'dailytime':
|
||||
hour = ntype.expiryhour if ntype.expiryhour is not None else 8
|
||||
return _next_eastern_time(starttime, hour, ntype.expiryminute or 0)
|
||||
if mode == 'duration' and ntype.expirydays:
|
||||
return starttime + timedelta(days=int(ntype.expirydays))
|
||||
if mode == 'none':
|
||||
# legacy fallback for rule-bearing types created before the expiry columns
|
||||
if getattr(ntype, 'typecolor', None) == 'recognition':
|
||||
return _next_eastern_time(starttime, 8, 0)
|
||||
if getattr(ntype, 'typecolor', None) == 'recertification':
|
||||
return starttime + timedelta(days=RECERTIFICATION_DAYS)
|
||||
return None
|
||||
|
||||
|
||||
_EXPIRY_MODES = ('none', 'duration', 'dailytime')
|
||||
|
||||
|
||||
def _apply_expiry_fields(t, data):
|
||||
"""Set expiry-rule columns on a NotificationType from request data. Only
|
||||
touches fields that are present. Returns an error string, or None on ok."""
|
||||
if 'expirymode' in data:
|
||||
mode = data.get('expirymode') or 'none'
|
||||
if mode not in _EXPIRY_MODES:
|
||||
return "expirymode must be one of: %s" % ", ".join(_EXPIRY_MODES)
|
||||
t.expirymode = mode
|
||||
if 'expirydays' in data:
|
||||
v = data.get('expirydays')
|
||||
if v in (None, ''):
|
||||
t.expirydays = None
|
||||
else:
|
||||
try:
|
||||
t.expirydays = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return "expirydays must be an integer"
|
||||
if t.expirydays < 1:
|
||||
return "expirydays must be >= 1"
|
||||
if 'expiryhour' in data:
|
||||
v = data.get('expiryhour')
|
||||
if v in (None, ''):
|
||||
t.expiryhour = None
|
||||
else:
|
||||
try:
|
||||
t.expiryhour = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return "expiryhour must be an integer"
|
||||
if not (0 <= t.expiryhour <= 23):
|
||||
return "expiryhour must be 0-23"
|
||||
if 'expiryminute' in data:
|
||||
v = data.get('expiryminute')
|
||||
try:
|
||||
t.expiryminute = int(v) if v not in (None, '') else 0
|
||||
except (TypeError, ValueError):
|
||||
return "expiryminute must be an integer"
|
||||
if not (0 <= t.expiryminute <= 59):
|
||||
return "expiryminute must be 0-59"
|
||||
# cross-field consistency
|
||||
mode = t.expirymode or 'none'
|
||||
if mode == 'dailytime' and t.expiryhour is None:
|
||||
t.expiryhour = 8
|
||||
if mode == 'duration' and not t.expirydays:
|
||||
return "duration expiry requires expirydays >= 1"
|
||||
return None
|
||||
|
||||
|
||||
_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')
|
||||
|
||||
|
||||
def _apply_display_fields(t, data):
|
||||
"""Set shopfloor display-behavior columns on a NotificationType from request
|
||||
data. Only touches fields that are present. Returns an error string, or None."""
|
||||
if 'splitperemployee' in data:
|
||||
t.splitperemployee = bool(data.get('splitperemployee'))
|
||||
if 'showemployeephoto' in data:
|
||||
t.showemployeephoto = bool(data.get('showemployeephoto'))
|
||||
if 'displaystyle' in data:
|
||||
ds = data.get('displaystyle') or 'standard'
|
||||
if ds not in _DISPLAY_STYLES:
|
||||
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
|
||||
t.displaystyle = ds
|
||||
return None
|
||||
|
||||
|
||||
def _config_version():
|
||||
"""Short hash of everything that changes the board's LAYOUT: per-type display
|
||||
config plus an optional deploy stamp (SHOPFLOOR_BUILD env). The shopfloor
|
||||
kiosks reload when this changes, so type/layout edits and frontend deploys
|
||||
reach pages that are already open."""
|
||||
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
||||
parts = [
|
||||
"%s|%s|%s|%d|%d|%s|%s|%s|%d" % (
|
||||
t.notificationtypeid, t.typecolor, t.displaystyle,
|
||||
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
||||
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
|
||||
)
|
||||
for t in types
|
||||
]
|
||||
parts.append(os.environ.get('SHOPFLOOR_BUILD', ''))
|
||||
return hashlib.md5('||'.join(parts).encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _employee_picture(sso):
|
||||
"""Resolved display photo URL for an SSO, via the shared employees-plugin
|
||||
resolver so kiosk cards match EmployeeDetail in both directory modes
|
||||
(self-hosted upload URL or external HR URL). None on any miss."""
|
||||
try:
|
||||
from plugins.employees.api.routes import resolve_employee_photo_url
|
||||
return resolve_employee_photo_url(sso)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Notification Types
|
||||
# =============================================================================
|
||||
|
||||
@notifications_bp.route('/types', methods=['GET'])
|
||||
def list_notification_types():
|
||||
"""List all notification types."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = NotificationType.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(NotificationType.isactive == True)
|
||||
|
||||
query = query.order_by(NotificationType.typename)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [t.to_dict() for t in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@notifications_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.create')
|
||||
def create_notification_type():
|
||||
"""Create a new notification type."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get('typename'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'typename is required')
|
||||
|
||||
if NotificationType.query.filter_by(typename=data['typename']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Notification type '{data['typename']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
t = NotificationType(
|
||||
typename=data['typename'],
|
||||
typedescription=data.get('typedescription') or data.get('description'),
|
||||
typecolor=data.get('typecolor') or data.get('color', '#17a2b8')
|
||||
)
|
||||
|
||||
err = _apply_expiry_fields(t, data)
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
err = _apply_display_fields(t, data)
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
|
||||
db.session.add(t)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(t.to_dict(), message='Notification type created', http_code=201)
|
||||
|
||||
|
||||
@notifications_bp.route('/types/<int:type_id>', methods=['PUT', 'PATCH'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.create')
|
||||
def update_notification_type(type_id: int):
|
||||
"""Update a notification type, including its auto-expiry rule."""
|
||||
t = db.session.get(NotificationType, type_id)
|
||||
if not t:
|
||||
return error_response(ErrorCodes.NOT_FOUND, f'Notification type {type_id} not found', http_code=404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
if data.get('typename'):
|
||||
dup = NotificationType.query.filter(
|
||||
NotificationType.typename == data['typename'],
|
||||
NotificationType.notificationtypeid != type_id
|
||||
).first()
|
||||
if dup:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Notification type '{data['typename']}' already exists", http_code=409)
|
||||
t.typename = data['typename']
|
||||
if 'typedescription' in data or 'description' in data:
|
||||
t.typedescription = data.get('typedescription') or data.get('description')
|
||||
if 'typecolor' in data or 'color' in data:
|
||||
t.typecolor = data.get('typecolor') or data.get('color')
|
||||
if 'isactive' in data:
|
||||
t.isactive = bool(data['isactive'])
|
||||
|
||||
err = _apply_expiry_fields(t, data)
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
err = _apply_display_fields(t, data)
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
|
||||
db.session.commit()
|
||||
return success_response(t.to_dict(), message='Notification type updated')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Notifications CRUD
|
||||
# =============================================================================
|
||||
|
||||
@notifications_bp.route('', methods=['GET'])
|
||||
def list_notifications():
|
||||
"""
|
||||
List all notifications with filtering and pagination.
|
||||
|
||||
Query parameters:
|
||||
- page, per_page: Pagination
|
||||
- active: Filter by active status (default: true)
|
||||
- type_id: Filter by notification type ID
|
||||
- current: Filter to currently active notifications only
|
||||
- search: Search in notification text
|
||||
"""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = Notification.query
|
||||
|
||||
# Active filter
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(Notification.isactive == True)
|
||||
|
||||
# Type filter
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Notification.notificationtypeid == int(type_id))
|
||||
|
||||
# Exact-match lookup for idempotent import. Notifications have no strong
|
||||
# natural key; ticketnumber is the best available when a ticket is set.
|
||||
if exactticket := request.args.get('ticketnumber'):
|
||||
query = query.filter(Notification.ticketnumber == exactticket)
|
||||
|
||||
# Current filter (active based on dates)
|
||||
if request.args.get('current', 'false').lower() == 'true':
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
query = query.filter(
|
||||
Notification.starttime <= now,
|
||||
db.or_(
|
||||
Notification.endtime.is_(None),
|
||||
Notification.endtime >= now
|
||||
)
|
||||
)
|
||||
|
||||
# Search filter
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(
|
||||
Notification.notification.ilike(f'%{search}%')
|
||||
)
|
||||
|
||||
# Sorting by start time (newest first)
|
||||
query = query.order_by(Notification.starttime.desc())
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [n.to_dict() for n in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@notifications_bp.route('/<int:notification_id>', methods=['GET'])
|
||||
def get_notification(notification_id: int):
|
||||
"""Get a single notification."""
|
||||
n = db.session.get(Notification, notification_id)
|
||||
|
||||
if not n:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Notification with ID {notification_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(n.to_dict())
|
||||
|
||||
|
||||
@notifications_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.create')
|
||||
def create_notification():
|
||||
"""Create a new notification."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Validate required fields
|
||||
notification_text = data.get('notification') or data.get('message')
|
||||
if not notification_text:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required')
|
||||
|
||||
# Parse dates
|
||||
starttime = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
if data.get('starttime') or data.get('startdate'):
|
||||
try:
|
||||
date_str = data.get('starttime') or data.get('startdate')
|
||||
starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
|
||||
|
||||
endtime = None
|
||||
if data.get('endtime') or data.get('enddate'):
|
||||
try:
|
||||
date_str = data.get('endtime') or data.get('enddate')
|
||||
endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
|
||||
|
||||
# No explicit end time: apply the per-type display window (recognition
|
||||
# clears at the next 8 AM Eastern, recertification runs two weeks).
|
||||
if endtime is None and data.get('notificationtypeid'):
|
||||
ntype = db.session.get(NotificationType, data['notificationtypeid'])
|
||||
if ntype:
|
||||
endtime = _auto_endtime(ntype, starttime)
|
||||
|
||||
n = Notification(
|
||||
notification=notification_text,
|
||||
notificationtypeid=data.get('notificationtypeid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
appid=data.get('appid'),
|
||||
starttime=starttime,
|
||||
endtime=endtime,
|
||||
ticketnumber=data.get('ticketnumber'),
|
||||
link=data.get('link') or data.get('linkurl'),
|
||||
isactive=True,
|
||||
isshopfloor=data.get('isshopfloor', False),
|
||||
employeesso=data.get('employeesso'),
|
||||
employeename=data.get('employeename')
|
||||
)
|
||||
|
||||
db.session.add(n)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(n.to_dict(), message='Notification created', http_code=201)
|
||||
|
||||
|
||||
@notifications_bp.route('/<int:notification_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.edit')
|
||||
def update_notification(notification_id: int):
|
||||
"""Update a notification."""
|
||||
n = db.session.get(Notification, notification_id)
|
||||
|
||||
if not n:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Notification with ID {notification_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Update text content
|
||||
if 'notification' in data or 'message' in data:
|
||||
n.notification = data.get('notification') or data.get('message')
|
||||
|
||||
# Update simple fields
|
||||
if 'notificationtypeid' in data:
|
||||
n.notificationtypeid = data['notificationtypeid']
|
||||
if 'businessunitid' in data:
|
||||
n.businessunitid = data['businessunitid']
|
||||
if 'appid' in data:
|
||||
n.appid = data['appid']
|
||||
if 'ticketnumber' in data:
|
||||
n.ticketnumber = data['ticketnumber']
|
||||
if 'link' in data or 'linkurl' in data:
|
||||
n.link = data.get('link') or data.get('linkurl')
|
||||
if 'isactive' in data:
|
||||
n.isactive = data['isactive']
|
||||
if 'isshopfloor' in data:
|
||||
n.isshopfloor = data['isshopfloor']
|
||||
if 'employeesso' in data:
|
||||
n.employeesso = data['employeesso']
|
||||
if 'employeename' in data:
|
||||
n.employeename = data['employeename']
|
||||
|
||||
# Parse and update dates
|
||||
if 'starttime' in data or 'startdate' in data:
|
||||
date_str = data.get('starttime') or data.get('startdate')
|
||||
if date_str:
|
||||
try:
|
||||
n.starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
|
||||
else:
|
||||
n.starttime = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if 'endtime' in data or 'enddate' in data:
|
||||
date_str = data.get('endtime') or data.get('enddate')
|
||||
if date_str:
|
||||
try:
|
||||
n.endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
|
||||
else:
|
||||
n.endtime = None
|
||||
|
||||
db.session.commit()
|
||||
return success_response(n.to_dict(), message='Notification updated')
|
||||
|
||||
|
||||
@notifications_bp.route('/<int:notification_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.delete')
|
||||
def delete_notification(notification_id: int):
|
||||
"""Delete (soft delete) a notification."""
|
||||
n = db.session.get(Notification, notification_id)
|
||||
|
||||
if not n:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Notification with ID {notification_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
n.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Notification deleted')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Special Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@notifications_bp.route('/active', methods=['GET'])
|
||||
def get_active_notifications():
|
||||
"""
|
||||
Get currently active notifications for display.
|
||||
"""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
from datetime import timedelta
|
||||
lookahead = now + timedelta(days=10)
|
||||
|
||||
notifications = Notification.query.filter(
|
||||
Notification.isactive == True,
|
||||
db.or_(
|
||||
Notification.starttime.is_(None),
|
||||
Notification.starttime <= lookahead
|
||||
),
|
||||
db.or_(
|
||||
Notification.endtime.is_(None),
|
||||
Notification.endtime >= now
|
||||
)
|
||||
).order_by(Notification.starttime.asc()).all()
|
||||
|
||||
data = [n.to_dict() for n in notifications]
|
||||
|
||||
return success_response({
|
||||
'notifications': data,
|
||||
'total': len(data)
|
||||
})
|
||||
|
||||
|
||||
@notifications_bp.route('/calendar', methods=['GET'])
|
||||
def get_calendar_events():
|
||||
"""
|
||||
Get notifications in FullCalendar event format.
|
||||
|
||||
Query parameters:
|
||||
- start: Start date (ISO format)
|
||||
- end: End date (ISO format)
|
||||
"""
|
||||
query = Notification.query.filter(Notification.isactive == True)
|
||||
|
||||
# Date range filter
|
||||
if start := request.args.get('start'):
|
||||
try:
|
||||
start_date = datetime.fromisoformat(start.replace('Z', '+00:00'))
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Notification.endtime >= start_date,
|
||||
Notification.endtime.is_(None)
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end := request.args.get('end'):
|
||||
try:
|
||||
end_date = datetime.fromisoformat(end.replace('Z', '+00:00'))
|
||||
query = query.filter(Notification.starttime <= end_date)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
notifications = query.order_by(Notification.starttime).all()
|
||||
|
||||
events = [n.to_calendar_event() for n in notifications]
|
||||
|
||||
return success_response(events)
|
||||
|
||||
|
||||
@notifications_bp.route('/dashboard/summary', methods=['GET'])
|
||||
def dashboard_summary():
|
||||
"""Get notifications dashboard summary."""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
# Total active notifications
|
||||
total_active = Notification.query.filter(
|
||||
Notification.isactive == True,
|
||||
db.or_(
|
||||
Notification.starttime.is_(None),
|
||||
Notification.starttime <= now
|
||||
),
|
||||
db.or_(
|
||||
Notification.endtime.is_(None),
|
||||
Notification.endtime >= now
|
||||
)
|
||||
).count()
|
||||
|
||||
# By type
|
||||
by_type = db.session.query(
|
||||
NotificationType.typename,
|
||||
NotificationType.typecolor,
|
||||
db.func.count(Notification.notificationid)
|
||||
).join(Notification
|
||||
).filter(
|
||||
Notification.isactive == True
|
||||
).group_by(NotificationType.typename, NotificationType.typecolor
|
||||
).all()
|
||||
|
||||
return success_response({
|
||||
'active': total_active,
|
||||
'bytype': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type]
|
||||
})
|
||||
|
||||
|
||||
@notifications_bp.route('/employee/<sso>', methods=['GET'])
|
||||
def get_employee_recognitions(sso):
|
||||
"""
|
||||
Get recognitions for a specific employee by SSO.
|
||||
|
||||
Returns all recognition-type notifications where the employee is mentioned.
|
||||
"""
|
||||
if not sso or not sso.isdigit():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Valid SSO required')
|
||||
|
||||
# Find recognition type(s)
|
||||
recognition_types = NotificationType.query.filter(
|
||||
db.or_(
|
||||
NotificationType.typecolor == 'recognition',
|
||||
NotificationType.typename.ilike('%recognition%')
|
||||
)
|
||||
).all()
|
||||
|
||||
recognition_type_ids = [rt.notificationtypeid for rt in recognition_types]
|
||||
|
||||
# Find notifications where this employee is mentioned
|
||||
# Check both exact match and comma-separated list
|
||||
query = Notification.query.filter(
|
||||
Notification.isactive == True,
|
||||
db.or_(
|
||||
Notification.employeesso == sso,
|
||||
Notification.employeesso.like(f'{sso},%'),
|
||||
Notification.employeesso.like(f'%,{sso}'),
|
||||
Notification.employeesso.like(f'%,{sso},%')
|
||||
)
|
||||
)
|
||||
|
||||
# Optionally filter to recognition types only
|
||||
if recognition_type_ids:
|
||||
query = query.filter(Notification.notificationtypeid.in_(recognition_type_ids))
|
||||
|
||||
query = query.order_by(Notification.starttime.desc())
|
||||
|
||||
notifications = query.all()
|
||||
data = [n.to_dict() for n in notifications]
|
||||
|
||||
return success_response({
|
||||
'recognitions': data,
|
||||
'total': len(data)
|
||||
})
|
||||
|
||||
|
||||
@notifications_bp.route('/shopfloor', methods=['GET'])
|
||||
def get_shopfloor_notifications():
|
||||
"""
|
||||
Get notifications for shopfloor TV dashboard.
|
||||
|
||||
Returns current and upcoming notifications with isshopfloor=1.
|
||||
Splits multi-employee recognition and training into separate entries.
|
||||
|
||||
Query parameters:
|
||||
- businessunit: Filter by business unit ID (null = all units)
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
business_unit = request.args.get('businessunit')
|
||||
|
||||
# Base query for shopfloor notifications
|
||||
base_query = Notification.query.filter(Notification.isshopfloor == True)
|
||||
|
||||
# Business unit filter
|
||||
if business_unit and business_unit.isdigit():
|
||||
# Specific BU: show that BU's notifications AND null (all units)
|
||||
base_query = base_query.filter(
|
||||
db.or_(
|
||||
Notification.businessunitid == int(business_unit),
|
||||
Notification.businessunitid.is_(None)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# All units: only show notifications with NULL businessunitid
|
||||
base_query = base_query.filter(Notification.businessunitid.is_(None))
|
||||
|
||||
# Current notifications (active now or ended within 30 minutes)
|
||||
thirty_min_ago = now - timedelta(minutes=30)
|
||||
current_query = base_query.filter(
|
||||
db.or_(
|
||||
# Active and currently showing
|
||||
db.and_(
|
||||
Notification.isactive == True,
|
||||
db.or_(Notification.starttime.is_(None), Notification.starttime <= now),
|
||||
db.or_(Notification.endtime.is_(None), Notification.endtime >= now)
|
||||
),
|
||||
# Recently ended (within 30 min) - show as resolved
|
||||
db.and_(
|
||||
Notification.endtime.isnot(None),
|
||||
Notification.endtime >= thirty_min_ago,
|
||||
Notification.endtime < now
|
||||
)
|
||||
)
|
||||
).order_by(Notification.notificationid.desc())
|
||||
|
||||
current_notifications = current_query.all()
|
||||
|
||||
# Upcoming notifications (starts within next 5 days)
|
||||
five_days = now + timedelta(days=5)
|
||||
upcoming_query = base_query.filter(
|
||||
Notification.isactive == True,
|
||||
Notification.starttime > now,
|
||||
Notification.starttime <= five_days
|
||||
).order_by(Notification.starttime)
|
||||
|
||||
upcoming_notifications = upcoming_query.all()
|
||||
|
||||
def notification_to_shopfloor(n, employee_override=None):
|
||||
"""Convert notification to shopfloor format."""
|
||||
is_resolved = n.endtime and n.endtime < now
|
||||
ntype = n.notificationtype
|
||||
show_photo = bool(ntype and ntype.showemployeephoto)
|
||||
|
||||
result = {
|
||||
'notificationid': n.notificationid,
|
||||
'notification': n.notification,
|
||||
'starttime': n.starttime.isoformat() if n.starttime else None,
|
||||
'endtime': n.endtime.isoformat() if n.endtime else None,
|
||||
'ticketnumber': n.ticketnumber,
|
||||
'link': n.link,
|
||||
'isactive': n.isactive,
|
||||
'isshopfloor': True,
|
||||
'resolved': is_resolved,
|
||||
'typename': ntype.typename if ntype else None,
|
||||
'typecolor': ntype.typecolor if ntype else None,
|
||||
# Per-type display behavior the dashboard groups/renders by.
|
||||
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
|
||||
}
|
||||
|
||||
# Employee info (photo only when the type wants it)
|
||||
if employee_override:
|
||||
result['employeesso'] = employee_override.get('sso')
|
||||
result['employeename'] = employee_override.get('name')
|
||||
result['employeepicture'] = employee_override.get('picture')
|
||||
else:
|
||||
result['employeesso'] = n.employeesso
|
||||
result['employeename'] = n.employeename
|
||||
result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None
|
||||
|
||||
return result
|
||||
|
||||
def expand(n):
|
||||
"""One shopfloor card per notification, or one per employee when the
|
||||
type is configured to split multi-employee lists."""
|
||||
ntype = n.notificationtype
|
||||
is_split = bool(ntype and ntype.splitperemployee)
|
||||
if not (is_split and n.employeesso and ',' in n.employeesso):
|
||||
return [notification_to_shopfloor(n)]
|
||||
|
||||
show_photo = bool(ntype and ntype.showemployeephoto)
|
||||
ssos = [s.strip() for s in n.employeesso.split(',')]
|
||||
names = n.employeename.split(', ') if n.employeename else []
|
||||
return [
|
||||
notification_to_shopfloor(n, {
|
||||
'sso': sso,
|
||||
'name': names[i] if i < len(names) else sso,
|
||||
'picture': _employee_picture(sso) if show_photo else None,
|
||||
})
|
||||
for i, sso in enumerate(ssos)
|
||||
]
|
||||
|
||||
current_data = [card for n in current_notifications for card in expand(n)]
|
||||
upcoming_data = [card for n in upcoming_notifications for card in expand(n)]
|
||||
|
||||
return success_response({
|
||||
'timestamp': now.isoformat(),
|
||||
'current': current_data,
|
||||
'upcoming': upcoming_data,
|
||||
'configversion': _config_version(),
|
||||
})
|
||||
12
plugins/notifications/manifest.json
Normal file
12
plugins/notifications/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "notifications",
|
||||
"version": "1.0.0",
|
||||
"description": "Notifications and announcements management plugin",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/notifications",
|
||||
"provides": {
|
||||
"features": ["notifications", "announcements", "calendar_events"]
|
||||
}
|
||||
}
|
||||
14
plugins/notifications/migrations/env.py
Normal file
14
plugins/notifications/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the notifications plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which
|
||||
filters the metadata to this plugin's tables and drives Alembic against the
|
||||
per-plugin version table alembic_version_notifications. See ADR-008 for the
|
||||
ownership cutover between the core chain and per-plugin chains.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'notifications'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/notifications/migrations/script.py.mako
Normal file
24
plugins/notifications/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""notifications plugin anchor (ownership cutover).
|
||||
|
||||
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
|
||||
already created every table this plugin owns at the cutover point, so there is
|
||||
nothing to build here. This revision gives the plugin's own chain a base that
|
||||
`flask plugin upgrade-all` can stamp into alembic_version_notifications. From this
|
||||
anchor forward, new notifications schema changes land as 000N revisions in this
|
||||
directory, never in the core chain. See ADR-008.
|
||||
"""
|
||||
from alembic import op # noqa: F401
|
||||
import sqlalchemy as sa # noqa: F401
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'notifications0001anchor'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No-op: the core chain owns every table that exists at the cutover.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No-op: this anchor never unwinds core-owned tables.
|
||||
pass
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Index notifications.businessunitid (shopfloor list route filters on it).
|
||||
|
||||
businessunitid is a soft-ref column the shopfloor feed filters by; it had no
|
||||
index. Add ix_notifications_businessunitid (matches the model's index=True).
|
||||
|
||||
Idempotent; downgrade drops it.
|
||||
|
||||
Revision ID: notifications0002buidx
|
||||
Revises: notifications0001anchor
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'notifications0002buidx'
|
||||
down_revision = 'notifications0001anchor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_INDEX = 'ix_notifications_businessunitid'
|
||||
|
||||
|
||||
def _index_names(insp, table):
|
||||
return {i['name'] for i in insp.get_indexes(table)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'notifications' not in insp.get_table_names():
|
||||
return
|
||||
if _INDEX not in _index_names(insp, 'notifications'):
|
||||
op.create_index(_INDEX, 'notifications', ['businessunitid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'notifications' not in insp.get_table_names():
|
||||
return
|
||||
if _INDEX in _index_names(insp, 'notifications'):
|
||||
op.drop_index(_INDEX, table_name='notifications')
|
||||
5
plugins/notifications/models/__init__.py
Normal file
5
plugins/notifications/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Notifications plugin models."""
|
||||
|
||||
from .notification import Notification, NotificationType
|
||||
|
||||
__all__ = ['Notification', 'NotificationType']
|
||||
195
plugins/notifications/models/notification.py
Normal file
195
plugins/notifications/models/notification.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Notifications plugin models - adapted to existing database schema."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
class NotificationType(db.Model):
|
||||
"""
|
||||
Notification type classification.
|
||||
Matches existing notificationtypes table.
|
||||
"""
|
||||
__tablename__ = 'notificationtypes'
|
||||
|
||||
notificationtypeid = db.Column(db.Integer, primary_key=True)
|
||||
typename = db.Column(db.String(50), nullable=False)
|
||||
typedescription = db.Column(db.Text)
|
||||
typecolor = db.Column(db.String(20), default='#17a2b8')
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
|
||||
# Auto-expiry rule: when a notification of this type has no explicit end time,
|
||||
# how long it stays up on the shopfloor board.
|
||||
# 'none' -> indefinite (never auto-expires)
|
||||
# 'duration' -> starttime + expirydays days
|
||||
# 'dailytime' -> next expiryhour:expiryminute Eastern (daily reset)
|
||||
expirymode = db.Column(db.String(20), default='none')
|
||||
expirydays = db.Column(db.Integer, nullable=True)
|
||||
expiryhour = db.Column(db.SmallInteger, nullable=True)
|
||||
expiryminute = db.Column(db.SmallInteger, nullable=True, default=0)
|
||||
|
||||
# Shopfloor display behavior (data-driven; replaces hardcoded per-type logic).
|
||||
# splitperemployee -> one card per listed employee SSO
|
||||
# showemployeephoto -> resolve + show each employee's photo + name
|
||||
# displaystyle -> 'standard' (rows) | 'carousel' (rotating photo card)
|
||||
# | 'grid' (cycling row of tiles) | 'banner'
|
||||
splitperemployee = db.Column(db.Boolean, default=False)
|
||||
showemployeephoto = db.Column(db.Boolean, default=False)
|
||||
displaystyle = db.Column(db.String(20), default='standard')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NotificationType {self.typename}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'notificationtypeid': self.notificationtypeid,
|
||||
'typename': self.typename,
|
||||
'typedescription': self.typedescription,
|
||||
'typecolor': self.typecolor,
|
||||
'isactive': self.isactive,
|
||||
'expirymode': self.expirymode or 'none',
|
||||
'expirydays': self.expirydays,
|
||||
'expiryhour': self.expiryhour,
|
||||
'expiryminute': self.expiryminute if self.expiryminute is not None else 0,
|
||||
'splitperemployee': bool(self.splitperemployee),
|
||||
'showemployeephoto': bool(self.showemployeephoto),
|
||||
'displaystyle': self.displaystyle or 'standard'
|
||||
}
|
||||
|
||||
|
||||
class Notification(db.Model):
|
||||
"""
|
||||
Notification/announcement model.
|
||||
Matches existing notifications table schema.
|
||||
"""
|
||||
__tablename__ = 'notifications'
|
||||
|
||||
notificationid = db.Column(db.Integer, primary_key=True)
|
||||
notificationtypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('notificationtypes.notificationtypeid'),
|
||||
nullable=True
|
||||
)
|
||||
businessunitid = db.Column(db.Integer, nullable=True, index=True)
|
||||
appid = db.Column(db.Integer, nullable=True)
|
||||
notification = db.Column(db.Text, nullable=False, comment='The message content')
|
||||
starttime = db.Column(db.DateTime, nullable=True)
|
||||
endtime = db.Column(db.DateTime, nullable=True)
|
||||
ticketnumber = db.Column(db.String(50), nullable=True)
|
||||
link = db.Column(db.String(500), nullable=True)
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
isshopfloor = db.Column(db.Boolean, default=False)
|
||||
# TEXT (not VARCHAR): recognition/recertification notifications comma-join
|
||||
# every employee's SSO/name into one field, which overflows 100 chars once
|
||||
# ~11 people are listed.
|
||||
employeesso = db.Column(db.Text, nullable=True)
|
||||
employeename = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
notificationtype = db.relationship('NotificationType', backref='notifications')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Notification {self.notificationid}>"
|
||||
|
||||
@property
|
||||
def is_current(self):
|
||||
"""Check if notification is currently active based on dates."""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
if not self.isactive:
|
||||
return False
|
||||
if self.starttime and now < self.starttime:
|
||||
return False
|
||||
if self.endtime and now > self.endtime:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
"""Get title - first line or first 100 chars of notification."""
|
||||
if not self.notification:
|
||||
return ''
|
||||
lines = self.notification.split('\n')
|
||||
return lines[0][:100] if lines else self.notification[:100]
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related data."""
|
||||
result = {
|
||||
'notificationid': self.notificationid,
|
||||
'notificationtypeid': self.notificationtypeid,
|
||||
'businessunitid': self.businessunitid,
|
||||
'appid': self.appid,
|
||||
'notification': self.notification,
|
||||
'title': self.title,
|
||||
'message': self.notification,
|
||||
'starttime': self.starttime.isoformat() if self.starttime else None,
|
||||
'endtime': self.endtime.isoformat() if self.endtime else None,
|
||||
'startdate': self.starttime.isoformat() if self.starttime else None,
|
||||
'enddate': self.endtime.isoformat() if self.endtime else None,
|
||||
'ticketnumber': self.ticketnumber,
|
||||
'link': self.link,
|
||||
'linkurl': self.link,
|
||||
'isactive': bool(self.isactive) if self.isactive is not None else True,
|
||||
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
|
||||
'employeesso': self.employeesso,
|
||||
'employeename': self.employeename,
|
||||
'iscurrent': self.is_current
|
||||
}
|
||||
|
||||
# Add type info
|
||||
if self.notificationtype:
|
||||
result['typename'] = self.notificationtype.typename
|
||||
result['typecolor'] = self.notificationtype.typecolor
|
||||
|
||||
return result
|
||||
|
||||
def to_calendar_event(self):
|
||||
"""Convert to FullCalendar event format."""
|
||||
# Color is data-driven: types store a hex typecolor. Only the legacy
|
||||
# Bootstrap color-name aliases still need translating; hex passes through.
|
||||
color_aliases = {
|
||||
'success': '#04b962',
|
||||
'warning': '#ff8800',
|
||||
'danger': '#f5365c',
|
||||
'info': '#14abef',
|
||||
'primary': '#7934f3',
|
||||
'secondary': '#94614f',
|
||||
}
|
||||
|
||||
ntype = self.notificationtype
|
||||
raw_color = ntype.typecolor if ntype else '#14abef'
|
||||
color = color_aliases.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef')
|
||||
show_photo = bool(ntype and getattr(ntype, 'showemployeephoto', False))
|
||||
|
||||
# Employee-photo types prefix the card with the person's name/SSO. A
|
||||
# multi-person recognition/training lists many people, so summarize as
|
||||
# "First Person +N" to keep the calendar title short (the detail popup
|
||||
# still shows the full employeename).
|
||||
title = self.title
|
||||
if show_photo:
|
||||
employee_display = self.employeename or self.employeesso
|
||||
if employee_display:
|
||||
people = [p.strip() for p in employee_display.split(',') if p.strip()]
|
||||
if len(people) > 1:
|
||||
employee_display = f"{people[0]} +{len(people) - 1}"
|
||||
title = f"{employee_display}: {title}"
|
||||
|
||||
return {
|
||||
'id': self.notificationid,
|
||||
'title': title,
|
||||
'start': self.starttime.isoformat() if self.starttime else None,
|
||||
'end': self.endtime.isoformat() if self.endtime else None,
|
||||
'allDay': True,
|
||||
'backgroundColor': color,
|
||||
'borderColor': color,
|
||||
'extendedProps': {
|
||||
'notificationid': self.notificationid,
|
||||
'message': self.notification,
|
||||
'typename': ntype.typename if ntype else None,
|
||||
'typecolor': raw_color,
|
||||
'showemployeephoto': show_photo,
|
||||
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
|
||||
'linkurl': self.link,
|
||||
'ticketnumber': self.ticketnumber,
|
||||
'employeename': self.employeename,
|
||||
'employeesso': self.employeesso,
|
||||
}
|
||||
}
|
||||
231
plugins/notifications/plugin.py
Normal file
231
plugins/notifications/plugin.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Notifications plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db
|
||||
|
||||
from .models import Notification, NotificationType
|
||||
from .api import notifications_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationsPlugin(BasePlugin):
|
||||
"""
|
||||
Notifications plugin - manages announcements and notifications.
|
||||
|
||||
Provides functionality for:
|
||||
- Creating and managing notifications/announcements
|
||||
- Displaying banner notifications
|
||||
- Calendar view of notifications
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'notifications'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Notifications and announcements management'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/notifications'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return notifications_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Notification, NotificationType]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Notifications plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_notification_types()
|
||||
logger.info("Notifications plugin installed")
|
||||
|
||||
def _ensure_notification_types(self) -> None:
|
||||
"""Ensure default notification types exist.
|
||||
|
||||
For the special shopfloor types (recognition, training,
|
||||
recertification) the typecolor is a keyword the dashboard maps to a
|
||||
style and the feed uses to split multi-employee cards per employee;
|
||||
generic types use a hex color.
|
||||
"""
|
||||
# (typename, typedescription, typecolor, expirymode, expirydays,
|
||||
# expiryhour, splitperemployee, showemployeephoto, displaystyle)
|
||||
default_types = [
|
||||
('Awareness', 'General awareness notification', '#17a2b8', 'none', None, None, False, False, 'standard'),
|
||||
('Change', 'Planned change notification', '#ffc107', 'none', None, None, False, False, 'standard'),
|
||||
('Incident', 'Incident or outage notification', '#dc3545', 'none', None, None, False, False, 'standard'),
|
||||
('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'none', None, None, False, False, 'standard'),
|
||||
('General', 'General announcement', '#28a745', 'none', None, None, False, False, 'standard'),
|
||||
('Recognition', 'Employee recognition (clears at 8 AM Eastern)', '#ffc107', 'dailytime', None, 8, True, True, 'carousel'),
|
||||
('Training', 'Training notice (one card per employee)', '#17a2b8', 'none', None, None, True, True, 'carousel'),
|
||||
('Recertification', 'Employees due to retake a training course (shows two weeks)', '#0d6efd', 'duration', 14, None, True, True, 'grid'),
|
||||
]
|
||||
|
||||
for (typename, typedescription, typecolor, expirymode, expirydays,
|
||||
expiryhour, splitperemployee, showemployeephoto, displaystyle) in default_types:
|
||||
existing = NotificationType.query.filter_by(typename=typename).first()
|
||||
if not existing:
|
||||
t = NotificationType(
|
||||
typename=typename,
|
||||
typedescription=typedescription,
|
||||
typecolor=typecolor,
|
||||
expirymode=expirymode,
|
||||
expirydays=expirydays,
|
||||
expiryhour=expiryhour,
|
||||
expiryminute=0,
|
||||
splitperemployee=splitperemployee,
|
||||
showemployeephoto=showemployeephoto,
|
||||
displaystyle=displaystyle
|
||||
)
|
||||
db.session.add(t)
|
||||
logger.debug(f"Created notification type: {typename}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Notifications plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('notifications')
|
||||
def notifications_cli():
|
||||
"""Notifications plugin commands."""
|
||||
pass
|
||||
|
||||
@notifications_cli.command('list-types')
|
||||
def list_types():
|
||||
"""List all notification types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = NotificationType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No notification types found.')
|
||||
return
|
||||
|
||||
click.echo('Notification Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})")
|
||||
|
||||
@notifications_cli.command('stats')
|
||||
def stats():
|
||||
"""Show notification statistics."""
|
||||
from flask import current_app
|
||||
from datetime import datetime, timezone
|
||||
|
||||
with current_app.app_context():
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
total = Notification.query.filter(
|
||||
Notification.isactive == True
|
||||
).count()
|
||||
|
||||
active = Notification.query.filter(
|
||||
Notification.isactive == True,
|
||||
Notification.startdate <= now,
|
||||
db.or_(
|
||||
Notification.enddate.is_(None),
|
||||
Notification.enddate >= now
|
||||
)
|
||||
).count()
|
||||
|
||||
click.echo(f"Total notifications: {total}")
|
||||
click.echo(f"Currently active: {active}")
|
||||
|
||||
@notifications_cli.command('create')
|
||||
@click.option('--title', required=True, help='Notification title')
|
||||
@click.option('--message', required=True, help='Notification message')
|
||||
@click.option('--type', 'type_name', default='General', help='Notification type')
|
||||
def create_notification(title, message, type_name):
|
||||
"""Create a new notification."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
ntype = NotificationType.query.filter_by(typename=type_name).first()
|
||||
if not ntype:
|
||||
click.echo(f"Error: Notification type '{type_name}' not found.")
|
||||
return
|
||||
|
||||
n = Notification(
|
||||
title=title,
|
||||
message=message,
|
||||
notificationtypeid=ntype.notificationtypeid
|
||||
)
|
||||
db.session.add(n)
|
||||
db.session.commit()
|
||||
|
||||
click.echo(f"Created notification #{n.notificationid}: {title}")
|
||||
|
||||
return [notifications_cli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Active Notifications',
|
||||
'component': 'NotificationsWidget',
|
||||
'endpoint': '/api/notifications/dashboard/summary',
|
||||
'size': 'small',
|
||||
'position': 1,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Notifications',
|
||||
'icon': 'bell',
|
||||
'route': '/notifications',
|
||||
'position': 5,
|
||||
},
|
||||
{
|
||||
'name': 'Calendar',
|
||||
'icon': 'calendar',
|
||||
'route': '/calendar',
|
||||
'position': 6,
|
||||
},
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""Return the RBAC permissions this plugin owns."""
|
||||
return [
|
||||
('notifications.view', 'View notifications', 'notifications'),
|
||||
('notifications.create', 'Create notifications', 'notifications'),
|
||||
('notifications.edit', 'Edit notifications', 'notifications'),
|
||||
('notifications.delete', 'Delete notifications', 'notifications'),
|
||||
]
|
||||
5
plugins/printers/__init__.py
Normal file
5
plugins/printers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Printers plugin - extends machines with printer-specific functionality."""
|
||||
|
||||
from .plugin import PrintersPlugin
|
||||
|
||||
__all__ = ['PrintersPlugin']
|
||||
7
plugins/printers/api/__init__.py
Normal file
7
plugins/printers/api/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Printers plugin API."""
|
||||
|
||||
from .asset_routes import printers_asset_bp # Asset-based API
|
||||
|
||||
__all__ = [
|
||||
'printers_asset_bp',
|
||||
]
|
||||
1156
plugins/printers/api/asset_routes.py
Normal file
1156
plugins/printers/api/asset_routes.py
Normal file
File diff suppressed because it is too large
Load Diff
25
plugins/printers/manifest.json
Normal file
25
plugins/printers/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "printers",
|
||||
"version": "1.0.0",
|
||||
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/printers",
|
||||
"provides": {
|
||||
"machine_category": "Printer",
|
||||
"features": [
|
||||
"printer_extensions",
|
||||
"driver_management",
|
||||
"supply_tracking",
|
||||
"zabbix_integration",
|
||||
"qr_codes"
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"zabbix_url": "",
|
||||
"zabbix_token": "",
|
||||
"supply_alert_threshold": 10,
|
||||
"default_driver_source": "internal"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user