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

@@ -62,7 +62,7 @@ class Computer(BaseModel):
nullable=True
)
# Hardware make/model (PCs carry vendor + model like equipment)
# Hardware make/model (PCs carry vendor + model like machines)
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),

View File

@@ -1,5 +0,0 @@
"""Equipment plugin for ShopDB."""
from .plugin import EquipmentPlugin
__all__ = ['EquipmentPlugin']

View File

@@ -1,5 +0,0 @@
"""Equipment plugin API."""
from .routes import equipment_bp
__all__ = ['equipment_bp']

View File

@@ -1,8 +0,0 @@
"""Equipment plugin models."""
from .equipment import Equipment, EquipmentType
__all__ = [
'Equipment',
'EquipmentType',
]

View File

@@ -0,0 +1,5 @@
"""Machines plugin for ShopDB."""
from .plugin import MachinesPlugin
__all__ = ['MachinesPlugin']

View File

@@ -0,0 +1,5 @@
"""Machines plugin API."""
from .routes import machines_bp
__all__ = ['machines_bp']

View File

@@ -1,36 +1,36 @@
"""Equipment plugin API endpoints."""
"""Machines plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Equipment, EquipmentType
from ..models import Machine, MachineType
from shopdb.api import require_permission, require_role
equipment_bp = Blueprint('equipment', __name__)
machines_bp = Blueprint('machines', __name__)
# =============================================================================
# Equipment Types
# Machine Types
# =============================================================================
@equipment_bp.route('/types', methods=['GET'])
@machines_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
def list_equipment_types():
"""List all equipment types."""
def list_machine_types():
"""List all machine types."""
page, per_page = get_pagination_params(request)
query = EquipmentType.query
query = MachineType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(EquipmentType.isactive == True)
query = query.filter(MachineType.isactive == True)
if search := request.args.get('search'):
query = query.filter(EquipmentType.equipmenttype.ilike(f'%{search}%'))
query = query.filter(MachineType.machinetype.ilike(f'%{search}%'))
query = query.order_by(EquipmentType.equipmenttype)
query = query.order_by(MachineType.machinetype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
@@ -38,33 +38,33 @@ def list_equipment_types():
return paginated_response(data, page, per_page, total)
@equipment_bp.route('/types/<int:type_id>', methods=['GET'])
@machines_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_equipment_type(type_id: int):
"""Get a single equipment type."""
t = db.session.get(EquipmentType, type_id)
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'Equipment type with ID {type_id} not found',
f'Machine type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@equipment_bp.route('/types', methods=['POST'])
@machines_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('equipment.create')
def create_equipment_type():
"""Create a new equipment type."""
@require_permission('machines.create')
def create_machine_type():
"""Create a new machine type."""
data = request.get_json()
if not data or not data.get('equipmenttype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'equipmenttype is required')
if not data or not data.get('machinetype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required')
existing = EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first()
existing = MachineType.query.filter_by(machinetype=data['machinetype']).first()
if existing:
if not existing.isactive:
existing.isactive = True
@@ -75,12 +75,12 @@ def create_equipment_type():
return success_response(existing.to_dict(), message='Reactivated existing type')
return error_response(
ErrorCodes.CONFLICT,
f"Equipment type '{data['equipmenttype']}' already exists",
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
t = EquipmentType(
equipmenttype=data['equipmenttype'],
t = MachineType(
machinetype=data['machinetype'],
description=data.get('description'),
icon=data.get('icon'), color=data.get('color')
)
@@ -88,20 +88,20 @@ def create_equipment_type():
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Equipment type created', http_code=201)
return success_response(t.to_dict(), message='Machine type created', http_code=201)
@equipment_bp.route('/types/<int:type_id>', methods=['PUT'])
@machines_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('equipment.edit')
def update_equipment_type(type_id: int):
"""Update an equipment type."""
t = db.session.get(EquipmentType, type_id)
@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'Equipment type with ID {type_id} not found',
f'Machine type with ID {type_id} not found',
http_code=404
)
@@ -109,62 +109,62 @@ def update_equipment_type(type_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'equipmenttype' in data and data['equipmenttype'] != t.equipmenttype:
if EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first():
if 'machinetype' in data and data['machinetype'] != t.machinetype:
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Equipment type '{data['equipmenttype']}' already exists",
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
for key in ['equipmenttype', 'description', 'icon', 'color', 'isactive']:
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='Equipment type updated')
return success_response(t.to_dict(), message='Machine type updated')
@equipment_bp.route('/types/<int:type_id>', methods=['DELETE'])
@machines_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('equipment.delete')
def delete_equipment_type(type_id: int):
"""Delete an equipment type. Refused if any asset still uses it."""
t = db.session.get(EquipmentType, type_id)
@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, 'Equipment type not found', http_code=404)
inuse = Equipment.query.filter_by(equipmenttypeid=type_id).count()
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='Equipment type deleted')
return success_response(message='Machine type deleted')
# =============================================================================
# Equipment CRUD
# Machine CRUD
# =============================================================================
@equipment_bp.route('', methods=['GET'])
@machines_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_equipment():
def list_machines():
"""
List all equipment with filtering and pagination.
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 equipment type ID
- 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 Equipment with Asset
query = db.session.query(Equipment).join(Asset)
# Join Machine with Asset
query = db.session.query(Machine).join(Asset)
# Active filter
if request.args.get('active', 'true').lower() != 'false':
@@ -180,13 +180,13 @@ def list_equipment():
)
)
# Equipment type filter
# Machine type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Equipment.equipmenttypeid == int(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(Equipment.vendorid == int(vendor_id))
query = query.filter(Machine.vendorid == int(vendor_id))
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
@@ -211,67 +211,67 @@ def list_equipment():
items, total = paginate_query(query, page, per_page)
# Build response with both asset and equipment data
# Build response with both asset and machine data
data = []
for equip in items:
item = equip.asset.to_dict() if equip.asset else {}
item['equipment'] = equip.to_dict()
for mach in items:
item = mach.asset.to_dict() if mach.asset else {}
item['machine'] = mach.to_dict()
data.append(item)
return paginated_response(data, page, per_page, total)
@equipment_bp.route('/<int:equipment_id>', methods=['GET'])
@machines_bp.route('/<int:machine_id>', methods=['GET'])
@jwt_required(optional=True)
def get_equipment(equipment_id: int):
"""Get a single equipment item with full details."""
equip = db.session.get(Equipment, equipment_id)
def get_machine(machine_id: int):
"""Get a single machine item with full details."""
mach = db.session.get(Machine, machine_id)
if not equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
f'Machine with ID {machine_id} not found',
http_code=404
)
result = equip.asset.to_dict() if equip.asset else {}
result['equipment'] = equip.to_dict()
result = mach.asset.to_dict() if mach.asset else {}
result['machine'] = mach.to_dict()
return success_response(result)
@equipment_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@machines_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
def get_equipment_by_asset(asset_id: int):
"""Get equipment data by asset ID."""
equip = Equipment.query.filter_by(assetid=asset_id).first()
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 equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment for asset {asset_id} not found',
f'Machine for asset {asset_id} not found',
http_code=404
)
result = equip.asset.to_dict() if equip.asset else {}
result['equipment'] = equip.to_dict()
result = mach.asset.to_dict() if mach.asset else {}
result['machine'] = mach.to_dict()
return success_response(result)
@equipment_bp.route('', methods=['POST'])
@machines_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('equipment.create')
def create_equipment():
@require_permission('machines.create')
def create_machine():
"""
Create new equipment (creates both Asset and Equipment records).
Create new machine (creates both Asset and Machine records).
Required fields:
- assetnumber: Business identifier
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- equipmenttypeid, vendorid, modelnumberid
- machinetypeid, vendorid, modelnumberid
- requiresmanualconfig, islocationonly
- mapx, mapy, notes
"""
@@ -291,12 +291,12 @@ def create_equipment():
http_code=409
)
# Get equipment asset type
equipment_type = AssetType.query.filter_by(assettype='equipment').first()
if not equipment_type:
# Get machine asset type
machine_type = AssetType.query.filter_by(assettype='machine').first()
if not machine_type:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Equipment asset type not found. Plugin may not be properly installed.',
'Machine asset type not found. Plugin may not be properly installed.',
http_code=500
)
@@ -307,7 +307,7 @@ def create_equipment():
gaugelabreference=data.get('gaugelabreference'),
maintenancereference=data.get('maintenancereference'),
serialnumber=data.get('serialnumber'),
assettypeid=equipment_type.assettypeid,
assettypeid=machine_type.assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
@@ -319,10 +319,10 @@ def create_equipment():
db.session.add(asset)
db.session.flush() # Get the assetid
# Create the equipment extension
equip = Equipment(
# Create the machine extension
mach = Machine(
assetid=asset.assetid,
equipmenttypeid=data.get('equipmenttypeid'),
machinetypeid=data.get('machinetypeid'),
vendorid=data.get('vendorid'),
modelnumberid=data.get('modelnumberid'),
requiresmanualconfig=data.get('requiresmanualconfig', False),
@@ -334,32 +334,32 @@ def create_equipment():
controllermodelid=data.get('controllermodelid')
)
db.session.add(equip)
db.session.add(mach)
db.session.flush()
# Audit log
AuditLog.log('created', 'Equipment', entityid=equip.equipmentid,
AuditLog.log('created', 'Machine', entityid=mach.machineid,
entityname=data['assetnumber'])
db.session.commit()
result = asset.to_dict()
result['equipment'] = equip.to_dict()
result['machine'] = mach.to_dict()
return success_response(result, message='Equipment created', http_code=201)
return success_response(result, message='Machine created', http_code=201)
@equipment_bp.route('/<int:equipment_id>', methods=['PUT'])
@machines_bp.route('/<int:machine_id>', methods=['PUT'])
@jwt_required()
@require_permission('equipment.edit')
def update_equipment(equipment_id: int):
"""Update equipment (both Asset and Equipment records)."""
equip = db.session.get(Equipment, equipment_id)
@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 equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
f'Machine with ID {machine_id} not found',
http_code=404
)
@@ -367,7 +367,7 @@ def update_equipment(equipment_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
asset = equip.asset
asset = mach.asset
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
@@ -394,87 +394,87 @@ def update_equipment(equipment_id: int):
changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key])
# Update equipment fields
equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
'controllervendorid', 'controllermodelid']
for key in equipment_fields:
# 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(equip, key)
old_val = getattr(mach, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(equip, key, data[key])
setattr(mach, key, data[key])
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Equipment', entityid=equip.equipmentid,
AuditLog.log('updated', 'Machine', entityid=mach.machineid,
entityname=asset.assetnumber, changes=changes)
db.session.commit()
result = asset.to_dict()
result['equipment'] = equip.to_dict()
result['machine'] = mach.to_dict()
return success_response(result, message='Equipment updated')
return success_response(result, message='Machine updated')
@equipment_bp.route('/<int:equipment_id>', methods=['DELETE'])
@machines_bp.route('/<int:machine_id>', methods=['DELETE'])
@jwt_required()
@require_permission('equipment.delete')
def delete_equipment(equipment_id: int):
"""Delete (soft delete) equipment."""
equip = db.session.get(Equipment, equipment_id)
@require_permission('machines.delete')
def delete_machine(machine_id: int):
"""Delete (soft delete) machine."""
mach = db.session.get(Machine, machine_id)
if not equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
f'Machine with ID {machine_id} not found',
http_code=404
)
# Soft delete the asset (equipment extension will stay linked)
equip.asset.isactive = False
# Soft delete the asset (machine extension will stay linked)
mach.asset.isactive = False
# Audit log
AuditLog.log('deleted', 'Equipment', entityid=equip.equipmentid,
entityname=equip.asset.assetnumber)
AuditLog.log('deleted', 'Machine', entityid=mach.machineid,
entityname=mach.asset.assetnumber)
db.session.commit()
return success_response(message='Equipment deleted')
return success_response(message='Machine deleted')
# =============================================================================
# Dashboard
# =============================================================================
@equipment_bp.route('/dashboard/summary', methods=['GET'])
@machines_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required(optional=True)
def dashboard_summary():
"""Get equipment dashboard summary data."""
# Total active equipment count
total = db.session.query(Equipment).join(Asset).filter(
"""Get machine dashboard summary data."""
# Total active machine count
total = db.session.query(Machine).join(Asset).filter(
Asset.isactive == True
).count()
# Count by equipment type
# Count by machine type
by_type = db.session.query(
EquipmentType.equipmenttype,
db.func.count(Equipment.equipmentid)
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).join(Asset, Asset.assetid == Equipment.assetid
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(EquipmentType.equipmenttype
).group_by(MachineType.machinetype
).all()
# Count by status
from shopdb.api import AssetStatus
by_status = db.session.query(
AssetStatus.status,
db.func.count(Equipment.equipmentid)
).join(Asset, Asset.assetid == Equipment.assetid
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

View File

@@ -1,22 +1,22 @@
{
"name": "equipment",
"version": "1.0.0",
"description": "Equipment management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing equipment",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/equipment",
"provides": {
"asset_type": "equipment",
"features": [
"equipment_tracking",
"maintenance_scheduling",
"vendor_management",
"model_catalog"
]
},
"settings": {
"enable_maintenance_alerts": true,
"maintenance_alert_days": 30
}
}
{
"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
}
}

View File

@@ -1,13 +1,13 @@
"""Alembic environment for the equipment plugin migration chain.
"""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_equipment. See ADR-008 for 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'] = 'equipment'
os.environ['PLUGIN_NAME'] = 'machines'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402

View File

@@ -1,11 +1,15 @@
"""equipment plugin anchor (ownership cutover).
"""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_equipment. From this
anchor forward, new equipment schema changes land as 000N revisions in this
`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

View File

@@ -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")

View File

@@ -0,0 +1,8 @@
"""Machines plugin models."""
from .machine import Machine, MachineType
__all__ = [
'Machine',
'MachineType',
]

View File

@@ -1,36 +1,36 @@
"""Equipment plugin models."""
"""Machines plugin models."""
from shopdb.api import db, BaseModel
class EquipmentType(BaseModel):
class MachineType(BaseModel):
"""
Equipment type classification.
Machine type classification.
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc.
"""
__tablename__ = 'equipmenttypes'
__tablename__ = 'machinetypes'
equipmenttypeid = db.Column(db.Integer, primary_key=True)
equipmenttype = db.Column(db.String(100), unique=True, nullable=False)
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"<EquipmentType {self.equipmenttype}>"
return f"<MachineType {self.machinetype}>"
class Equipment(BaseModel):
class Machine(BaseModel):
"""
Equipment-specific extension data.
Machine-specific extension data.
Links to core Asset table via assetid.
Stores equipment-specific fields like type, model, vendor, etc.
Stores machine-specific fields like type, model, vendor, etc.
"""
__tablename__ = 'equipment'
__tablename__ = 'machines'
equipmentid = db.Column(db.Integer, primary_key=True)
machineid = db.Column(db.Integer, primary_key=True)
# Link to core asset
assetid = db.Column(
@@ -41,10 +41,10 @@ class Equipment(BaseModel):
index=True
)
# Equipment classification
equipmenttypeid = db.Column(
# Machine classification
machinetypeid = db.Column(
db.Integer,
db.ForeignKey('equipmenttypes.equipmenttypeid'),
db.ForeignKey('machinetypes.machinetypeid'),
nullable=True
)
@@ -60,7 +60,7 @@ class Equipment(BaseModel):
nullable=True
)
# Equipment-specific fields
# Machine-specific fields
requiresmanualconfig = db.Column(
db.Boolean,
default=False,
@@ -69,7 +69,7 @@ class Equipment(BaseModel):
islocationonly = db.Column(
db.Boolean,
default=False,
comment='Virtual location marker (not actual equipment)'
comment='Virtual location marker (not actual machine)'
)
# Maintenance tracking
@@ -94,29 +94,29 @@ class Equipment(BaseModel):
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('equipment', uselist=False, lazy='joined')
backref=db.backref('machine', uselist=False, lazy='joined')
)
equipmenttype = db.relationship('EquipmentType', backref='equipment')
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items')
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items')
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers')
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
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_equipment_type', 'equipmenttypeid'),
db.Index('idx_equipment_vendor', 'vendorid'),
db.Index('idx_machine_type', 'machinetypeid'),
db.Index('idx_machine_vendor', 'vendorid'),
)
def __repr__(self):
return f"<Equipment {self.assetid}>"
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.equipmenttype:
result['equipmenttypename'] = self.equipmenttype.equipmenttype
if self.machinetype:
result['machinetypename'] = self.machinetype.machinetype
if self.vendor:
result['vendorname'] = self.vendor.vendor
if self.model:

View File

@@ -1,4 +1,4 @@
"""Equipment plugin main class."""
"""Machines plugin main class."""
import json
import logging
@@ -11,18 +11,18 @@ import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType, AssetStatus
from .models import Equipment, EquipmentType
from .api import equipment_bp
from .models import Machine, MachineType
from .api import machines_bp
logger = logging.getLogger(__name__)
class EquipmentPlugin(BasePlugin):
class MachinesPlugin(BasePlugin):
"""
Equipment plugin - manages manufacturing equipment assets.
Machines plugin - manages manufacturing machine assets.
Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
Uses the new Asset architecture with Equipment extension table.
Machines include CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
Uses the new Asset architecture with Machine extension table.
"""
def __init__(self):
@@ -40,51 +40,51 @@ class EquipmentPlugin(BasePlugin):
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'equipment'),
name=self._manifest.get('name', 'machines'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Equipment management for manufacturing assets'
'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/equipment'),
api_prefix=self._manifest.get('api_prefix', '/api/machines'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return equipment_bp
return machines_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [Equipment, EquipmentType]
return [Machine, MachineType]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Equipment plugin initialized (v{self.meta.version})")
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_equipment_types()
logger.info("Equipment plugin installed")
self._ensure_machine_types()
logger.info("Machines plugin installed")
def _ensure_asset_type(self) -> None:
"""Ensure equipment asset type exists."""
existing = AssetType.query.filter_by(assettype='equipment').first()
"""Ensure machine asset type exists."""
existing = AssetType.query.filter_by(assettype='machine').first()
if not existing:
at = AssetType(
assettype='equipment',
pluginname='equipment',
tablename='equipment',
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
assettype='machine',
pluginname='machines',
tablename='machines',
description='Manufacturing machines (CNCs, CMMs, lathes, etc.)',
icon='cog'
)
db.session.add(at)
logger.debug("Created asset type: equipment")
logger.debug("Created asset type: machine")
db.session.commit()
def _ensure_asset_statuses(self) -> None:
@@ -110,82 +110,82 @@ class EquipmentPlugin(BasePlugin):
db.session.commit()
def _ensure_equipment_types(self) -> None:
"""Ensure basic equipment types exist."""
equipment_types = [
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 equipment', 'marker'),
('Part Marker', 'Part marking/engraving machine', 'marker'),
('Mill', 'Milling machine', 'mill'),
('Press', 'Press machine', 'press'),
('Robot', 'Industrial robot', 'robot'),
('Other', 'Other equipment type', 'cog'),
('Other', 'Other machine type', 'cog'),
]
for name, description, icon in equipment_types:
existing = EquipmentType.query.filter_by(equipmenttype=name).first()
for name, description, icon in machine_types:
existing = MachineType.query.filter_by(machinetype=name).first()
if not existing:
et = EquipmentType(
equipmenttype=name,
mt = MachineType(
machinetype=name,
description=description,
icon=icon
)
db.session.add(et)
logger.debug(f"Created equipment type: {name}")
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("Equipment plugin uninstalled")
logger.info("Machines plugin uninstalled")
def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin."""
@click.group('equipment')
def equipmentcli():
"""Equipment plugin commands."""
@click.group('machines')
def machinescli():
"""Machines plugin commands."""
pass
@equipmentcli.command('list-types')
@machinescli.command('list-types')
def list_types():
"""List all equipment types."""
"""List all machine types."""
from flask import current_app
with current_app.app_context():
types = EquipmentType.query.filter_by(isactive=True).all()
types = MachineType.query.filter_by(isactive=True).all()
if not types:
click.echo('No equipment types found.')
click.echo('No machine types found.')
return
click.echo('Equipment Types:')
click.echo('Machine Types:')
for t in types:
click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}")
click.echo(f" [{t.machinetypeid}] {t.machinetype}")
@equipmentcli.command('stats')
@machinescli.command('stats')
def stats():
"""Show equipment statistics."""
"""Show machine statistics."""
from flask import current_app
from shopdb.api import Asset
with current_app.app_context():
total = db.session.query(Equipment).join(Asset).filter(
total = db.session.query(Machine).join(Asset).filter(
Asset.isactive == True
).count()
click.echo(f"Total active equipment: {total}")
click.echo(f"Total active machines: {total}")
# By type
by_type = db.session.query(
EquipmentType.equipmenttype,
db.func.count(Equipment.equipmentid)
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).join(Asset, Asset.assetid == Equipment.assetid
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(EquipmentType.equipmenttype
).group_by(MachineType.machinetype
).all()
if by_type:
@@ -193,15 +193,15 @@ class EquipmentPlugin(BasePlugin):
for t, c in by_type:
click.echo(f" {t}: {c}")
return [equipmentcli]
return [machinescli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
return [
{
'name': 'Machine Status',
'component': 'EquipmentStatusWidget',
'endpoint': '/api/equipment/dashboard/summary',
'component': 'MachineStatusWidget',
'endpoint': '/api/machines/dashboard/summary',
'size': 'medium',
'position': 5,
},

View File

@@ -2,7 +2,7 @@
Measuring tools are gage-lab instruments (calipers, micrometers, thread gages,
bore gages, height gages, Genspect heads, ...) that measure parts, as opposed
to equipment that makes parts (see ADR-005). Each tool is a core Asset plus a
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:
@@ -50,7 +50,7 @@ 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 equipment/computer/printer type tables.
the same shape as the machine/computer/printer type tables.
"""
__tablename__ = 'measuringtooltypes'

View File

@@ -317,7 +317,7 @@ def seedsupplies():
targets = _matching_models(family['matchkeys'], vendor.vendorid)
if not targets:
# machinetypeid is a legacy Model column (nullable); printers are
# modeltypeid is a legacy Model column (nullable); printers are
# asset-based now and carry their type via PrinterType, not here.
model = Model(
modelnumber=family['canonical'],

View File

@@ -2,7 +2,7 @@
Asset-general plugin: owns its warranties + warrantyassets tables, an API
surface, and a sidebar entry. Not tied to any one asset type - a warranty can
cover a PC, printer, network device, or equipment.
cover a PC, printer, network device, or machine.
"""
import json