Rename the equipment domain to machines; retype the models catalog (ADR-011)
All checks were successful
CI / backend (push) Successful in 23s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

The equipment plugin is now the machines plugin, ending the UI-vs-code
vocabulary split while the contract is pre-1.0 and nothing external
depends on the old names.

- plugins/equipment -> plugins/machines: manifest, class, /api/machines,
  machines.* permissions, registry key (with an auto-migrating load shim
  for existing installs).
- Tables: equipment -> machines (equipmentid -> machineid) and
  equipmenttypes -> machinetypes, renamed in the plugin's own migration
  chain (machines0002rename), idempotent for both upgrading and fresh
  installs.
- The legacy core machinetypes lookup actually types the vendor MODELS
  catalog, so it is renamed losslessly to modeltypes
  (models.modeltypeid, /api/modeltypes, Model Types settings page)
  rather than collapsed, freeing the machinetypes name. Core migration
  7d17_machines_rename also flips data in place: assettypes row
  equipment -> machine, auditlog entitytype, identifier_/search_
  settings keys, permission rows, and renames alembic_version_equipment.
- Frontend: machinesApi/modeltypesApi, item.machine response shape,
  assettype value compares 'equipment' -> 'machine' (map, search,
  custom fields, relationships), routes machines.js with plugin gating
  retagged, /print/machine-badge, Machine Types (subtypes) and Model
  Types (catalog) settings pages, machines-by-type report id.
- Docs swept; ADRs left as history per the authoring rule.

Upgrade: flask db upgrade then flask plugin upgrade-all.

Verified: dev DB flipped live (262 machines, 35 modeltypes, 95 models
retyped, zero equipment tables remain); fresh scratch-MySQL install
produces the new names; 341 tests green; naming/style green; frontend
builds; live E2E on machines list/detail, PC relationships, map,
reports, and both settings pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 15:17:42 -04:00
parent 3c43c8d5c8
commit 48d3160bc5
84 changed files with 4755 additions and 4317 deletions

View File

@@ -2,7 +2,7 @@
from .auth import auth_bp
from .assets import assets_bp
from .machinetypes import machinetypes_bp
from .modeltypes import modeltypes_bp
from .plugins import plugins_bp
from .vendors import vendors_bp
from .models import models_bp
@@ -24,7 +24,7 @@ from .setup import setup_bp
__all__ = [
'auth_bp',
'assets_bp',
'machinetypes_bp',
'modeltypes_bp',
'plugins_bp',
'vendors_bp',
'models_bp',

View File

@@ -332,7 +332,7 @@ def list_assets():
- per_page: Items per page (default: 20, max: 100)
- active: Filter by active status (default: true)
- search: Search by assetnumber or name
- type: Filter by asset type name (e.g., 'equipment', 'computer')
- type: Filter by asset type name (e.g., 'machine', 'computer')
- type_id: Filter by asset type ID
- status_id: Filter by status ID
- location_id: Filter by location ID
@@ -699,8 +699,8 @@ def get_assets_map():
Returns assets with mapx/mapy coordinates, joined with type-specific data.
Query parameters:
- assettype: Filter by asset type name (equipment, computer, network_device, printer)
- subtype: Filter by subtype ID (machinetype for equipment/computer, networkdevicetype for network, printertype for printer)
- assettype: Filter by asset type name (machine, computer, network_device, printer)
- subtype: Filter by subtype ID (machinetype for machines, computertype for PCs, networkdevicetype for network, printertype for printer)
- businessunitid: Filter by business unit ID
- statusid: Filter by status ID
- locationid: Filter by location ID
@@ -720,26 +720,26 @@ def get_assets_map():
# Eager-load plugin extension tables AND their relationships
try:
from plugins.equipment.models import Equipment
from plugins.machines.models import Machine
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.equipmenttype)
subqueryload(Asset.machine)
.joinedload(Machine.machinetype)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.vendor)
subqueryload(Asset.machine)
.joinedload(Machine.vendor)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.model)
subqueryload(Asset.machine)
.joinedload(Machine.model)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.controllervendor)
subqueryload(Asset.machine)
.joinedload(Machine.controllervendor)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.controllermodel)
subqueryload(Asset.machine)
.joinedload(Machine.controllermodel)
)
except (ImportError, AttributeError):
pass
@@ -800,11 +800,11 @@ def get_assets_map():
if subtype_id := request.args.get('subtype'):
subtype_id = int(subtype_id)
asset_type_lower = selected_assettype.lower() if selected_assettype else ''
if asset_type_lower == 'equipment':
if asset_type_lower == 'machine':
try:
from plugins.equipment.models import Equipment
query = query.join(Equipment, Equipment.assetid == Asset.assetid).filter(
Equipment.equipmenttypeid == subtype_id
from plugins.machines.models import Machine
query = query.join(Machine, Machine.assetid == Asset.assetid).filter(
Machine.machinetypeid == subtype_id
)
except ImportError:
pass
@@ -926,11 +926,11 @@ def get_assets_map():
subtypes = {}
try:
from plugins.equipment.models import EquipmentType
equipment_types = EquipmentType.query.filter(EquipmentType.isactive == True).order_by(EquipmentType.equipmenttype).all()
subtypes['Equipment'] = [{'id': et.equipmenttypeid, 'name': et.equipmenttype, 'color': et.color} for et in equipment_types]
from plugins.machines.models import MachineType
machine_types = MachineType.query.filter(MachineType.isactive == True).order_by(MachineType.machinetype).all()
subtypes['Machine'] = [{'id': mt.machinetypeid, 'name': mt.machinetype, 'color': mt.color} for mt in machine_types]
except ImportError:
subtypes['Equipment'] = []
subtypes['Machine'] = []
try:
from plugins.computers.models import ComputerType

View File

@@ -11,7 +11,7 @@ dashboard_bp = Blueprint('dashboard', __name__)
# Map asset type name -> dashboard category label
_TYPE_CATEGORY = {
'equipment': 'Equipment',
'machine': 'Machine',
'computer': 'PC',
'printer': 'Printer',
'network_device': 'Network',
@@ -30,11 +30,11 @@ def _count_by_type(assettype):
@jwt_required(optional=True)
def get_dashboard():
"""Get dashboard summary data (asset-based)."""
equipment_count = _count_by_type('equipment')
machine_count = _count_by_type('machine')
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
printer_count = _count_by_type('printer')
total = equipment_count + pc_count + network_count + printer_count
total = machine_count + pc_count + network_count + printer_count
# Count by status
status_counts = db.session.query(
@@ -52,17 +52,18 @@ def get_dashboard():
).limit(10).all()
return success_response({
# Fields expected by frontend
'totalmachines': total,
'totalequipment': equipment_count,
# Fields expected by frontend (totalmachines now means machines,
# totalassets is the grand total - renamed with the machines plugin)
'totalassets': total,
'totalmachines': machine_count,
'totalpc': pc_count,
'totalnetwork': network_count,
'totalprinter': printer_count,
'activemachines': status_dict.get('In Use', 0),
'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0),
# Structured data
'counts': {
'equipment': equipment_count,
'machines': machine_count,
'pcs': pc_count,
'networkdevices': network_count,
'printers': printer_count,

View File

@@ -1,153 +0,0 @@
"""Machine Types API endpoints - Full CRUD."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from shopdb.extensions import db
from shopdb.core.models import MachineType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
machinetypes_bp = Blueprint('machinetypes', __name__)
@machinetypes_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_machinetypes():
"""List all machine types with optional filtering."""
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 category := request.args.get('category'):
query = query.filter(MachineType.category == category)
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 = [mt.to_dict() for mt in items]
return paginated_response(data, page, per_page, total)
@machinetypes_bp.route('/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_machinetype(type_id: int):
"""Get a single machine type."""
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine type with ID {type_id} not found',
http_code=404
)
return success_response(mt.to_dict())
@machinetypes_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_machinetype():
"""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')
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
mt = MachineType(
machinetype=data['machinetype'],
category=data.get('category', 'Equipment'),
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(mt)
db.session.commit()
return success_response(mt.to_dict(), message='Machine type created', http_code=201)
@machinetypes_bp.route('/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_machinetype(type_id: int):
"""Update a machine type."""
mt = db.session.get(MachineType, type_id)
if not mt:
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')
# Check duplicate name
if 'machinetype' in data and data['machinetype'] != mt.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', 'category', 'description', 'icon', 'isactive']:
if key in data:
setattr(mt, key, data[key])
db.session.commit()
return success_response(mt.to_dict(), message='Machine type updated')
@machinetypes_bp.route('/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_machinetype(type_id: int):
"""Delete (deactivate) a machine type."""
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine type with ID {type_id} not found',
http_code=404
)
# Check if any model uses this type
from shopdb.core.models import Model
if Model.query.filter_by(machinetypeid=type_id).first():
return error_response(
ErrorCodes.CONFLICT,
'Cannot delete machine type: models are using it',
http_code=409
)
mt.isactive = False
db.session.commit()
return success_response(message='Machine type deleted')

View File

@@ -1,4 +1,4 @@
"""Models (equipment models) API endpoints - Full CRUD."""
"""Models (vendor model catalog) API endpoints - Full CRUD."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
@@ -21,7 +21,7 @@ models_bp = Blueprint('models', __name__)
@models_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_models():
"""List all equipment models."""
"""List all vendor catalog models."""
page, per_page = get_pagination_params(request)
query = Model.query
@@ -32,8 +32,8 @@ def list_models():
if vendor_id := request.args.get('vendor', type=int):
query = query.filter(Model.vendorid == vendor_id)
if machinetype_id := request.args.get('machinetype', type=int):
query = query.filter(Model.machinetypeid == machinetype_id)
if modeltype_id := request.args.get('modeltype', type=int):
query = query.filter(Model.modeltypeid == modeltype_id)
if search := request.args.get('search'):
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
@@ -46,7 +46,7 @@ def list_models():
for m in items:
d = m.to_dict()
d['vendor'] = m.vendor.vendor if m.vendor else None
d['machinetype'] = m.machinetype.machinetype if m.machinetype else None
d['modeltype'] = m.modeltype.modeltype if m.modeltype else None
data.append(d)
return paginated_response(data, page, per_page, total)
@@ -67,7 +67,7 @@ def get_model(model_id: int):
data = m.to_dict()
data['vendor'] = m.vendor.to_dict() if m.vendor else None
data['machinetype'] = m.machinetype.to_dict() if m.machinetype else None
data['modeltype'] = m.modeltype.to_dict() if m.modeltype else None
return success_response(data)
@@ -97,7 +97,7 @@ def create_model():
m = Model(
modelnumber=data['modelnumber'],
vendorid=data.get('vendorid'),
machinetypeid=data.get('machinetypeid'),
modeltypeid=data.get('modeltypeid'),
description=data.get('description'),
imageurl=data.get('imageurl'),
documentationurl=data.get('documentationurl'),
@@ -128,7 +128,7 @@ def update_model(model_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
for key in ['modelnumber', 'vendorid', 'machinetypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
if key in data:
setattr(m, key, data[key])

View File

@@ -0,0 +1,157 @@
"""Model Types API endpoints - Full CRUD.
Types the vendor MODELS catalog (models.modeltypeid). Renamed from
machinetypes; the machines plugin now owns the "machinetypes" name.
"""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from shopdb.extensions import db
from shopdb.core.models import ModelType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
modeltypes_bp = Blueprint('modeltypes', __name__)
@modeltypes_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_modeltypes():
"""List all model types with optional filtering."""
page, per_page = get_pagination_params(request)
query = ModelType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(ModelType.isactive == True)
if category := request.args.get('category'):
query = query.filter(ModelType.category == category)
if search := request.args.get('search'):
query = query.filter(ModelType.modeltype.ilike(f'%{search}%'))
query = query.order_by(ModelType.modeltype)
items, total = paginate_query(query, page, per_page)
data = [mt.to_dict() for mt in items]
return paginated_response(data, page, per_page, total)
@modeltypes_bp.route('/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_modeltype(type_id: int):
"""Get a single model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
return success_response(mt.to_dict())
@modeltypes_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_modeltype():
"""Create a new model type."""
data = request.get_json()
if not data or not data.get('modeltype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'modeltype is required')
if ModelType.query.filter_by(modeltype=data['modeltype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Model type '{data['modeltype']}' already exists",
http_code=409
)
mt = ModelType(
modeltype=data['modeltype'],
category=data.get('category', 'Equipment'),
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(mt)
db.session.commit()
return success_response(mt.to_dict(), message='Model type created', http_code=201)
@modeltypes_bp.route('/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_modeltype(type_id: int):
"""Update a model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model 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')
# Check duplicate name
if 'modeltype' in data and data['modeltype'] != mt.modeltype:
if ModelType.query.filter_by(modeltype=data['modeltype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Model type '{data['modeltype']}' already exists",
http_code=409
)
for key in ['modeltype', 'category', 'description', 'icon', 'isactive']:
if key in data:
setattr(mt, key, data[key])
db.session.commit()
return success_response(mt.to_dict(), message='Model type updated')
@modeltypes_bp.route('/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_modeltype(type_id: int):
"""Delete (deactivate) a model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
# Check if any model uses this type
from shopdb.core.models import Model
if Model.query.filter_by(modeltypeid=type_id).first():
return error_response(
ErrorCodes.CONFLICT,
'Cannot delete model type: models are using it',
http_code=409
)
mt.isactive = False
db.session.commit()
return success_response(message='Model type deleted')

File diff suppressed because it is too large Load Diff

View File

@@ -110,8 +110,8 @@ def _get_asset_result(asset, query, relevance=None):
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
plugin_id = asset.assetid
if asset_type_name == 'equipment' and hasattr(asset, 'equipment') and asset.equipment:
plugin_id = asset.equipment.equipmentid
if asset_type_name == 'machine' and hasattr(asset, 'machine') and asset.machine:
plugin_id = asset.machine.machineid
elif asset_type_name == 'computer' and hasattr(asset, 'computer') and asset.computer:
plugin_id = asset.computer.computerid
elif asset_type_name == 'network_device' and hasattr(asset, 'network_device') and asset.network_device:
@@ -120,7 +120,7 @@ def _get_asset_result(asset, query, relevance=None):
plugin_id = asset.printer.printerid
url_map = {
'equipment': f"/machines/{plugin_id}",
'machine': f"/machines/{plugin_id}",
'computer': f"/pcs/{plugin_id}",
'network_device': f"/network/{plugin_id}",
'printer': f"/printers/{plugin_id}",
@@ -478,21 +478,21 @@ def _search_notifications(query, search_term):
def _search_vendor_model_type(query, search_term):
"""Search assets by vendor name, model name, or equipment/device type name."""
"""Search assets by vendor name, model name, or machine/device type name."""
results = []
# Equipment: vendor, model, equipmenttype
# Machines: vendor, model, machinetype
try:
_require_enabled('equipment')
from plugins.equipment.models import Equipment, EquipmentType
equipment_assets = db.session.query(Asset).join(
Equipment, Equipment.assetid == Asset.assetid
_require_enabled('machines')
from plugins.machines.models import Machine, MachineType
machine_assets = db.session.query(Asset).join(
Machine, Machine.assetid == Asset.assetid
).outerjoin(
Vendor, Equipment.vendorid == Vendor.vendorid
Vendor, Machine.vendorid == Vendor.vendorid
).outerjoin(
Model, Equipment.modelnumberid == Model.modelnumberid
Model, Machine.modelnumberid == Model.modelnumberid
).outerjoin(
EquipmentType, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
MachineType, Machine.machinetypeid == MachineType.machinetypeid
).options(
joinedload(Asset.assettype),
joinedload(Asset.location),
@@ -501,16 +501,16 @@ def _search_vendor_model_type(query, search_term):
db.or_(
Vendor.vendor.ilike(search_term),
Model.modelnumber.ilike(search_term),
EquipmentType.equipmenttype.ilike(search_term)
MachineType.machinetype.ilike(search_term)
)
).limit(10).all()
for asset in equipment_assets:
for asset in machine_assets:
results.append(_get_asset_result(asset, query, 30))
except ImportError:
pass
except Exception as e:
logger.error(f"Equipment vendor/model/type search failed: {e}")
logger.error(f"Machine vendor/model/type search failed: {e}")
# Printers: vendor, model, printertype
try:

View File

@@ -53,7 +53,7 @@ IDENTIFIER_LABELS = {
'maintenancereference': 'Maintenance Reference',
'fqdn': 'FQDN / hostname',
}
IDENTIFIER_ASSETTYPES = ['equipment', 'computer', 'printer', 'network_device']
IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device']
# Global-search result types that can be toggled on/off independently of whether
# the owning plugin is enabled. Keys match the `type` field on search results;
@@ -63,7 +63,7 @@ SEARCH_DOMAINS = {
'application': 'Applications',
'knowledgebase': 'Knowledge Base',
'employee': 'Employees',
'equipment': 'Equipment',
'machine': 'Machines',
'computer': 'PCs',
'printer': 'Printers',
'network_device': 'Network Devices',
@@ -450,7 +450,7 @@ def build_default_settings():
'value': '/ge-aerospace-logo.svg',
'valuetype': 'string',
'category': 'branding',
'description': 'Logo shown on the equipment badge print page'
'description': 'Logo shown on the machine badge print page'
},
{
'key': 'site_favicon',