Rename the equipment domain to machines; retype the models catalog (ADR-011)
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:
@@ -101,7 +101,7 @@ def create_app(config_name: str = None) -> Flask:
|
||||
CORE_BLUEPRINT_NAMES = (
|
||||
'auth',
|
||||
'assets',
|
||||
'machinetypes',
|
||||
'modeltypes',
|
||||
'plugins',
|
||||
'vendors',
|
||||
'models',
|
||||
|
||||
@@ -125,31 +125,31 @@ def seed_cli():
|
||||
@seed_cli.command('reference-data')
|
||||
@with_appcontext
|
||||
def seed_reference_data():
|
||||
"""Seed reference data (machine types, statuses, etc.)."""
|
||||
"""Seed reference data (model types, statuses, etc.)."""
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import MachineType, OperatingSystem, AssetStatus, LocationType
|
||||
from shopdb.core.models import ModelType, OperatingSystem, AssetStatus, LocationType
|
||||
from shopdb.core.models.relationship import RelationshipType
|
||||
|
||||
# Machine types
|
||||
machine_types = [
|
||||
{'machinetype': 'CNC Mill', 'category': 'Equipment', 'description': 'CNC Milling Machine'},
|
||||
{'machinetype': 'CNC Lathe', 'category': 'Equipment', 'description': 'CNC Lathe'},
|
||||
{'machinetype': 'CMM', 'category': 'Equipment', 'description': 'Coordinate Measuring Machine'},
|
||||
{'machinetype': 'EDM', 'category': 'Equipment', 'description': 'Electrical Discharge Machine'},
|
||||
{'machinetype': 'Grinder', 'category': 'Equipment', 'description': 'Grinding Machine'},
|
||||
{'machinetype': 'Inspection Station', 'category': 'Equipment', 'description': 'Inspection Station'},
|
||||
{'machinetype': 'Desktop PC', 'category': 'PC', 'description': 'Desktop Computer'},
|
||||
{'machinetype': 'Laptop', 'category': 'PC', 'description': 'Laptop Computer'},
|
||||
{'machinetype': 'Shopfloor PC', 'category': 'PC', 'description': 'Shopfloor Computer'},
|
||||
{'machinetype': 'Server', 'category': 'Network', 'description': 'Server'},
|
||||
{'machinetype': 'Switch', 'category': 'Network', 'description': 'Network Switch'},
|
||||
{'machinetype': 'Access Point', 'category': 'Network', 'description': 'Wireless Access Point'},
|
||||
# Model types (type the vendor models catalog)
|
||||
model_types = [
|
||||
{'modeltype': 'CNC Mill', 'category': 'Equipment', 'description': 'CNC Milling Machine'},
|
||||
{'modeltype': 'CNC Lathe', 'category': 'Equipment', 'description': 'CNC Lathe'},
|
||||
{'modeltype': 'CMM', 'category': 'Equipment', 'description': 'Coordinate Measuring Machine'},
|
||||
{'modeltype': 'EDM', 'category': 'Equipment', 'description': 'Electrical Discharge Machine'},
|
||||
{'modeltype': 'Grinder', 'category': 'Equipment', 'description': 'Grinding Machine'},
|
||||
{'modeltype': 'Inspection Station', 'category': 'Equipment', 'description': 'Inspection Station'},
|
||||
{'modeltype': 'Desktop PC', 'category': 'PC', 'description': 'Desktop Computer'},
|
||||
{'modeltype': 'Laptop', 'category': 'PC', 'description': 'Laptop Computer'},
|
||||
{'modeltype': 'Shopfloor PC', 'category': 'PC', 'description': 'Shopfloor Computer'},
|
||||
{'modeltype': 'Server', 'category': 'Network', 'description': 'Server'},
|
||||
{'modeltype': 'Switch', 'category': 'Network', 'description': 'Network Switch'},
|
||||
{'modeltype': 'Access Point', 'category': 'Network', 'description': 'Wireless Access Point'},
|
||||
]
|
||||
|
||||
for mt_data in machine_types:
|
||||
existing = MachineType.query.filter_by(machinetype=mt_data['machinetype']).first()
|
||||
for mt_data in model_types:
|
||||
existing = ModelType.query.filter_by(modeltype=mt_data['modeltype']).first()
|
||||
if not existing:
|
||||
mt = MachineType(**mt_data)
|
||||
mt = ModelType(**mt_data)
|
||||
db.session.add(mt)
|
||||
|
||||
# Asset statuses (canonical set - the asset model is the contract)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
@@ -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])
|
||||
|
||||
|
||||
157
shopdb/core/api/modeltypes.py
Normal file
157
shopdb/core/api/modeltypes.py
Normal 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
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
||||
from .asset import Asset, AssetType, AssetStatus
|
||||
from .machine import MachineType
|
||||
from .modeltype import ModelType
|
||||
from .vendor import Vendor
|
||||
from .model import Model
|
||||
from .businessunit import BusinessUnit
|
||||
@@ -26,8 +26,8 @@ __all__ = [
|
||||
'Asset',
|
||||
'AssetType',
|
||||
'AssetStatus',
|
||||
# Legacy machine type lookup (still referenced by models.machinetypeid)
|
||||
'MachineType',
|
||||
# Model-type lookup (referenced by models.modeltypeid)
|
||||
'ModelType',
|
||||
# Reference
|
||||
'Vendor',
|
||||
'Model',
|
||||
|
||||
@@ -1,274 +1,274 @@
|
||||
"""Polymorphic Asset models - core of the new asset architecture."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
||||
|
||||
|
||||
class AssetType(BaseModel):
|
||||
"""
|
||||
Registry of asset categories.
|
||||
|
||||
Each type maps to a plugin-owned extension table.
|
||||
Examples: equipment, computer, network_device, printer
|
||||
"""
|
||||
__tablename__ = 'assettypes'
|
||||
|
||||
assettypeid = db.Column(db.Integer, primary_key=True)
|
||||
assettype = db.Column(
|
||||
db.String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
comment='Category name: equipment, computer, network_device, printer'
|
||||
)
|
||||
pluginname = db.Column(
|
||||
db.String(100),
|
||||
nullable=True,
|
||||
comment='Plugin that owns this type'
|
||||
)
|
||||
tablename = db.Column(
|
||||
db.String(100),
|
||||
nullable=True,
|
||||
comment='Extension table name for this type'
|
||||
)
|
||||
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"<AssetType {self.assettype}>"
|
||||
|
||||
|
||||
class AssetStatus(BaseModel):
|
||||
"""Asset status options."""
|
||||
__tablename__ = 'assetstatuses'
|
||||
|
||||
statusid = db.Column(db.Integer, primary_key=True)
|
||||
status = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
color = db.Column(db.String(20), comment='CSS color for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AssetStatus {self.status}>"
|
||||
|
||||
|
||||
class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
"""
|
||||
Core asset model - minimal shared fields.
|
||||
|
||||
Category-specific data lives in plugin extension tables
|
||||
(equipment, computers, network_devices, printers).
|
||||
The assetid matches original machineid for migration compatibility.
|
||||
"""
|
||||
__tablename__ = 'assets'
|
||||
|
||||
assetid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Identification
|
||||
assetnumber = db.Column(
|
||||
db.String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'
|
||||
)
|
||||
name = db.Column(
|
||||
db.String(100),
|
||||
comment='Display name/alias'
|
||||
)
|
||||
gaugelabreference = db.Column(
|
||||
db.String(50),
|
||||
index=True,
|
||||
comment='Gauge lab asset reference (authoritative tag the gauge lab '
|
||||
'assigns to equipment); distinct from assetnumber'
|
||||
)
|
||||
maintenancereference = db.Column(
|
||||
db.String(50),
|
||||
index=True,
|
||||
comment='Maintenance system asset reference; distinct from assetnumber'
|
||||
)
|
||||
serialnumber = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Hardware serial number'
|
||||
)
|
||||
|
||||
# Classification
|
||||
assettypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assettypes.assettypeid'),
|
||||
nullable=False
|
||||
)
|
||||
statusid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assetstatuses.statusid'),
|
||||
default=1,
|
||||
comment='In Use, Spare, Retired, etc.'
|
||||
)
|
||||
|
||||
# Location and organization
|
||||
locationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('locations.locationid'),
|
||||
nullable=True
|
||||
)
|
||||
businessunitid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('businessunits.businessunitid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Floor map position (ADR-001: asset-specific override; nullable)
|
||||
mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)')
|
||||
mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)')
|
||||
|
||||
# Notes
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
assettype = db.relationship('AssetType', backref='assets')
|
||||
status = db.relationship('AssetStatus', backref='assets')
|
||||
location = db.relationship('Location', backref='assets')
|
||||
businessunit = db.relationship('BusinessUnit', backref='assets')
|
||||
|
||||
# Communications (one-to-many) - will be migrated to use assetid
|
||||
communications = db.relationship(
|
||||
'Communication',
|
||||
foreign_keys='Communication.assetid',
|
||||
backref='asset',
|
||||
cascade='all, delete-orphan',
|
||||
lazy='dynamic'
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'),
|
||||
db.Index('idx_asset_location', 'locationid'),
|
||||
db.Index('idx_asset_active', 'isactive'),
|
||||
db.Index('idx_asset_status', 'statusid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Asset {self.assetnumber}>"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get display name (name if set, otherwise assetnumber)."""
|
||||
return self.name or self.assetnumber
|
||||
|
||||
@property
|
||||
def primary_ip(self):
|
||||
"""Get primary IP address from communications."""
|
||||
comm = self.communications.filter_by(
|
||||
isprimary=True,
|
||||
comtypeid=1 # IP type
|
||||
).first()
|
||||
if comm:
|
||||
return comm.ipaddress
|
||||
# Fall back to any IP
|
||||
comm = self.communications.filter_by(comtypeid=1).first()
|
||||
return comm.ipaddress if comm else None
|
||||
|
||||
def get_inherited_location(self):
|
||||
"""
|
||||
Get location data from a related asset if this asset has none.
|
||||
|
||||
Returns dict with locationid, location_name, mapx, mapy, and
|
||||
inherited_from (assetnumber of source asset) if location was inherited.
|
||||
Returns None if no location data available.
|
||||
"""
|
||||
if self.locationid is not None or (self.mapx is not None and self.mapy is not None):
|
||||
return None
|
||||
|
||||
related_assets = []
|
||||
|
||||
if hasattr(self, 'incoming_relationships'):
|
||||
for rel in self.incoming_relationships:
|
||||
if rel.sourceasset and rel.isactive:
|
||||
related_assets.append(rel.sourceasset)
|
||||
|
||||
if hasattr(self, 'outgoing_relationships'):
|
||||
for rel in self.outgoing_relationships:
|
||||
if rel.targetasset and rel.isactive:
|
||||
related_assets.append(rel.targetasset)
|
||||
|
||||
for related in related_assets:
|
||||
if related.locationid is not None or (related.mapx is not None and related.mapy is not None):
|
||||
return {
|
||||
'locationid': related.locationid,
|
||||
'locationname': related.location.locationname if related.location else None,
|
||||
'mapx': related.mapx,
|
||||
'mapy': related.mapy,
|
||||
'inheritedfrom': related.assetnumber
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def to_dict(self, include_type_data=False, include_inherited_location=True):
|
||||
"""
|
||||
Convert model to dictionary.
|
||||
|
||||
Args:
|
||||
include_type_data: If True, include category-specific data from extension table
|
||||
include_inherited_location: If True, include location from related assets when missing
|
||||
"""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names for convenience
|
||||
if self.assettype:
|
||||
result['assettypename'] = self.assettype.assettype
|
||||
result['assettypecolor'] = getattr(self.assettype, 'color', None)
|
||||
if self.status:
|
||||
result['statusname'] = self.status.status
|
||||
result['statuscolor'] = self.status.color
|
||||
if self.location:
|
||||
result['locationname'] = self.location.locationname
|
||||
if self.businessunit:
|
||||
result['businessunitname'] = self.businessunit.businessunit
|
||||
|
||||
# Add plugin-specific ID for navigation purposes
|
||||
if hasattr(self, 'equipment') and self.equipment:
|
||||
result['pluginid'] = self.equipment.equipmentid
|
||||
elif hasattr(self, 'computer') and self.computer:
|
||||
result['pluginid'] = self.computer.computerid
|
||||
elif hasattr(self, 'network_device') and self.network_device:
|
||||
result['pluginid'] = self.network_device.networkdeviceid
|
||||
elif hasattr(self, 'printer') and self.printer:
|
||||
result['pluginid'] = self.printer.printerid
|
||||
|
||||
# Include inherited location if this asset has no location data
|
||||
if include_inherited_location:
|
||||
inherited = self.get_inherited_location()
|
||||
if inherited:
|
||||
result['inheritedlocation'] = inherited
|
||||
# Also set the location fields if they're missing
|
||||
if result.get('locationid') is None:
|
||||
result['locationid'] = inherited['locationid']
|
||||
result['locationname'] = inherited['locationname']
|
||||
if result.get('mapx') is None:
|
||||
result['mapx'] = inherited['mapx']
|
||||
if result.get('mapy') is None:
|
||||
result['mapy'] = inherited['mapy']
|
||||
|
||||
# Include extension data if requested
|
||||
if include_type_data:
|
||||
ext_data = self._get_extension_data()
|
||||
if ext_data:
|
||||
result['typedata'] = ext_data
|
||||
|
||||
return result
|
||||
|
||||
def _get_extension_data(self):
|
||||
"""Get category-specific data from extension table."""
|
||||
# Check for equipment extension
|
||||
if hasattr(self, 'equipment') and self.equipment:
|
||||
return self.equipment.to_dict()
|
||||
# Check for computer extension
|
||||
if hasattr(self, 'computer') and self.computer:
|
||||
return self.computer.to_dict()
|
||||
# Check for network_device extension
|
||||
if hasattr(self, 'network_device') and self.network_device:
|
||||
return self.network_device.to_dict()
|
||||
# Check for printer extension
|
||||
if hasattr(self, 'printer') and self.printer:
|
||||
return self.printer.to_dict()
|
||||
return None
|
||||
"""Polymorphic Asset models - core of the new asset architecture."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
||||
|
||||
|
||||
class AssetType(BaseModel):
|
||||
"""
|
||||
Registry of asset categories.
|
||||
|
||||
Each type maps to a plugin-owned extension table.
|
||||
Examples: machine, computer, network_device, printer
|
||||
"""
|
||||
__tablename__ = 'assettypes'
|
||||
|
||||
assettypeid = db.Column(db.Integer, primary_key=True)
|
||||
assettype = db.Column(
|
||||
db.String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
comment='Category name: machine, computer, network_device, printer'
|
||||
)
|
||||
pluginname = db.Column(
|
||||
db.String(100),
|
||||
nullable=True,
|
||||
comment='Plugin that owns this type'
|
||||
)
|
||||
tablename = db.Column(
|
||||
db.String(100),
|
||||
nullable=True,
|
||||
comment='Extension table name for this type'
|
||||
)
|
||||
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"<AssetType {self.assettype}>"
|
||||
|
||||
|
||||
class AssetStatus(BaseModel):
|
||||
"""Asset status options."""
|
||||
__tablename__ = 'assetstatuses'
|
||||
|
||||
statusid = db.Column(db.Integer, primary_key=True)
|
||||
status = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
color = db.Column(db.String(20), comment='CSS color for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AssetStatus {self.status}>"
|
||||
|
||||
|
||||
class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
"""
|
||||
Core asset model - minimal shared fields.
|
||||
|
||||
Category-specific data lives in plugin extension tables
|
||||
(machines, computers, network_devices, printers).
|
||||
The assetid matches original machineid for migration compatibility.
|
||||
"""
|
||||
__tablename__ = 'assets'
|
||||
|
||||
assetid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Identification
|
||||
assetnumber = db.Column(
|
||||
db.String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'
|
||||
)
|
||||
name = db.Column(
|
||||
db.String(100),
|
||||
comment='Display name/alias'
|
||||
)
|
||||
gaugelabreference = db.Column(
|
||||
db.String(50),
|
||||
index=True,
|
||||
comment='Gauge lab asset reference (authoritative tag the gauge lab '
|
||||
'assigns to machines); distinct from assetnumber'
|
||||
)
|
||||
maintenancereference = db.Column(
|
||||
db.String(50),
|
||||
index=True,
|
||||
comment='Maintenance system asset reference; distinct from assetnumber'
|
||||
)
|
||||
serialnumber = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Hardware serial number'
|
||||
)
|
||||
|
||||
# Classification
|
||||
assettypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assettypes.assettypeid'),
|
||||
nullable=False
|
||||
)
|
||||
statusid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assetstatuses.statusid'),
|
||||
default=1,
|
||||
comment='In Use, Spare, Retired, etc.'
|
||||
)
|
||||
|
||||
# Location and organization
|
||||
locationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('locations.locationid'),
|
||||
nullable=True
|
||||
)
|
||||
businessunitid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('businessunits.businessunitid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Floor map position (ADR-001: asset-specific override; nullable)
|
||||
mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)')
|
||||
mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)')
|
||||
|
||||
# Notes
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
assettype = db.relationship('AssetType', backref='assets')
|
||||
status = db.relationship('AssetStatus', backref='assets')
|
||||
location = db.relationship('Location', backref='assets')
|
||||
businessunit = db.relationship('BusinessUnit', backref='assets')
|
||||
|
||||
# Communications (one-to-many) - will be migrated to use assetid
|
||||
communications = db.relationship(
|
||||
'Communication',
|
||||
foreign_keys='Communication.assetid',
|
||||
backref='asset',
|
||||
cascade='all, delete-orphan',
|
||||
lazy='dynamic'
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'),
|
||||
db.Index('idx_asset_location', 'locationid'),
|
||||
db.Index('idx_asset_active', 'isactive'),
|
||||
db.Index('idx_asset_status', 'statusid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Asset {self.assetnumber}>"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get display name (name if set, otherwise assetnumber)."""
|
||||
return self.name or self.assetnumber
|
||||
|
||||
@property
|
||||
def primary_ip(self):
|
||||
"""Get primary IP address from communications."""
|
||||
comm = self.communications.filter_by(
|
||||
isprimary=True,
|
||||
comtypeid=1 # IP type
|
||||
).first()
|
||||
if comm:
|
||||
return comm.ipaddress
|
||||
# Fall back to any IP
|
||||
comm = self.communications.filter_by(comtypeid=1).first()
|
||||
return comm.ipaddress if comm else None
|
||||
|
||||
def get_inherited_location(self):
|
||||
"""
|
||||
Get location data from a related asset if this asset has none.
|
||||
|
||||
Returns dict with locationid, location_name, mapx, mapy, and
|
||||
inherited_from (assetnumber of source asset) if location was inherited.
|
||||
Returns None if no location data available.
|
||||
"""
|
||||
if self.locationid is not None or (self.mapx is not None and self.mapy is not None):
|
||||
return None
|
||||
|
||||
related_assets = []
|
||||
|
||||
if hasattr(self, 'incoming_relationships'):
|
||||
for rel in self.incoming_relationships:
|
||||
if rel.sourceasset and rel.isactive:
|
||||
related_assets.append(rel.sourceasset)
|
||||
|
||||
if hasattr(self, 'outgoing_relationships'):
|
||||
for rel in self.outgoing_relationships:
|
||||
if rel.targetasset and rel.isactive:
|
||||
related_assets.append(rel.targetasset)
|
||||
|
||||
for related in related_assets:
|
||||
if related.locationid is not None or (related.mapx is not None and related.mapy is not None):
|
||||
return {
|
||||
'locationid': related.locationid,
|
||||
'locationname': related.location.locationname if related.location else None,
|
||||
'mapx': related.mapx,
|
||||
'mapy': related.mapy,
|
||||
'inheritedfrom': related.assetnumber
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def to_dict(self, include_type_data=False, include_inherited_location=True):
|
||||
"""
|
||||
Convert model to dictionary.
|
||||
|
||||
Args:
|
||||
include_type_data: If True, include category-specific data from extension table
|
||||
include_inherited_location: If True, include location from related assets when missing
|
||||
"""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names for convenience
|
||||
if self.assettype:
|
||||
result['assettypename'] = self.assettype.assettype
|
||||
result['assettypecolor'] = getattr(self.assettype, 'color', None)
|
||||
if self.status:
|
||||
result['statusname'] = self.status.status
|
||||
result['statuscolor'] = self.status.color
|
||||
if self.location:
|
||||
result['locationname'] = self.location.locationname
|
||||
if self.businessunit:
|
||||
result['businessunitname'] = self.businessunit.businessunit
|
||||
|
||||
# Add plugin-specific ID for navigation purposes
|
||||
if hasattr(self, 'machine') and self.machine:
|
||||
result['pluginid'] = self.machine.machineid
|
||||
elif hasattr(self, 'computer') and self.computer:
|
||||
result['pluginid'] = self.computer.computerid
|
||||
elif hasattr(self, 'network_device') and self.network_device:
|
||||
result['pluginid'] = self.network_device.networkdeviceid
|
||||
elif hasattr(self, 'printer') and self.printer:
|
||||
result['pluginid'] = self.printer.printerid
|
||||
|
||||
# Include inherited location if this asset has no location data
|
||||
if include_inherited_location:
|
||||
inherited = self.get_inherited_location()
|
||||
if inherited:
|
||||
result['inheritedlocation'] = inherited
|
||||
# Also set the location fields if they're missing
|
||||
if result.get('locationid') is None:
|
||||
result['locationid'] = inherited['locationid']
|
||||
result['locationname'] = inherited['locationname']
|
||||
if result.get('mapx') is None:
|
||||
result['mapx'] = inherited['mapx']
|
||||
if result.get('mapy') is None:
|
||||
result['mapy'] = inherited['mapy']
|
||||
|
||||
# Include extension data if requested
|
||||
if include_type_data:
|
||||
ext_data = self._get_extension_data()
|
||||
if ext_data:
|
||||
result['typedata'] = ext_data
|
||||
|
||||
return result
|
||||
|
||||
def _get_extension_data(self):
|
||||
"""Get category-specific data from extension table."""
|
||||
# Check for machine extension
|
||||
if hasattr(self, 'machine') and self.machine:
|
||||
return self.machine.to_dict()
|
||||
# Check for computer extension
|
||||
if hasattr(self, 'computer') and self.computer:
|
||||
return self.computer.to_dict()
|
||||
# Check for network_device extension
|
||||
if hasattr(self, 'network_device') and self.network_device:
|
||||
return self.network_device.to_dict()
|
||||
# Check for printer extension
|
||||
if hasattr(self, 'printer') and self.printer:
|
||||
return self.printer.to_dict()
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Custom fields: site-defined extra attributes per asset type.
|
||||
|
||||
A CustomField is a definition scoped to one asset type (equipment, computer,
|
||||
A CustomField is a definition scoped to one asset type (machine, computer,
|
||||
printer, network_device). A CustomFieldValue holds one asset's value for one
|
||||
field. This is the generic form of the built-in identifier columns - sites add
|
||||
their own attributes without a schema change.
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Legacy machine type lookup.
|
||||
|
||||
The Machine instance model and its PC/status lookups were retired (ADR-001);
|
||||
assets are the platform contract. MachineType is kept only because the shared
|
||||
`models` table still references it via models.machinetypeid.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class MachineType(BaseModel):
|
||||
"""
|
||||
Machine type classification.
|
||||
Categories: Equipment, PC, Network, Printer
|
||||
"""
|
||||
__tablename__ = 'machinetypes'
|
||||
|
||||
machinetypeid = db.Column(db.Integer, primary_key=True)
|
||||
machinetype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
category = db.Column(
|
||||
db.String(50),
|
||||
nullable=False,
|
||||
default='Equipment',
|
||||
comment='Equipment, PC, Network, or Printer'
|
||||
)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MachineType {self.machinetype}>"
|
||||
@@ -1,20 +1,20 @@
|
||||
"""Model (equipment model number) model."""
|
||||
"""Model (vendor catalog model number) model."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
"""Equipment/device model information."""
|
||||
"""Vendor catalog model information (machines, PCs, printers, network)."""
|
||||
__tablename__ = 'models'
|
||||
|
||||
modelnumberid = db.Column(db.Integer, primary_key=True)
|
||||
modelnumber = db.Column(db.String(100), nullable=False)
|
||||
|
||||
# Link to machine type (what kind of equipment this model is for)
|
||||
machinetypeid = db.Column(
|
||||
# Link to model type (what kind of thing this catalog model is for)
|
||||
modeltypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('machinetypes.machinetypeid'),
|
||||
db.ForeignKey('modeltypes.modeltypeid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ class Model(BaseModel):
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
# Relationships
|
||||
machinetype = db.relationship('MachineType', backref='models')
|
||||
modeltype = db.relationship('ModelType', backref='models')
|
||||
vendor = db.relationship('Vendor', backref='models')
|
||||
|
||||
# Unique constraint on modelnumber + vendor
|
||||
|
||||
33
shopdb/core/models/modeltype.py
Normal file
33
shopdb/core/models/modeltype.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Model-type lookup (types the vendor MODELS catalog).
|
||||
|
||||
The Machine instance model and its PC/status lookups were retired (ADR-001);
|
||||
assets are the platform contract. ModelType is kept because the shared `models`
|
||||
table references it via models.modeltypeid: it types vendor models (Lathe,
|
||||
Switch, Laser Printer), not asset instances. Renamed from MachineType to be
|
||||
role-accurate and to free the "machinetype" name for the machines plugin.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class ModelType(BaseModel):
|
||||
"""
|
||||
Model-type classification (what kind of thing a catalog model is for).
|
||||
Categories: Equipment, PC, Network, Printer
|
||||
"""
|
||||
__tablename__ = 'modeltypes'
|
||||
|
||||
modeltypeid = db.Column(db.Integer, primary_key=True)
|
||||
modeltype = db.Column(db.String(100), unique=True, nullable=False)
|
||||
category = db.Column(
|
||||
db.String(50),
|
||||
nullable=False,
|
||||
default='Equipment',
|
||||
comment='Equipment, PC, Network, or Printer'
|
||||
)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModelType {self.modeltype}>"
|
||||
@@ -50,9 +50,9 @@ class AssetRelationship(BaseModel):
|
||||
Relationships between assets.
|
||||
|
||||
Examples:
|
||||
- Computer controls Equipment
|
||||
- Computer controls Machine
|
||||
- Two machines are dualpath partners
|
||||
- Network device connects to equipment
|
||||
- Network device connects to machine
|
||||
"""
|
||||
__tablename__ = 'assetrelationships'
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ class Permission(db.Model):
|
||||
('assets.create', 'Create assets', 'assets'),
|
||||
('assets.edit', 'Edit assets', 'assets'),
|
||||
('assets.delete', 'Delete assets', 'assets'),
|
||||
# Equipment
|
||||
('equipment.view', 'View equipment', 'equipment'),
|
||||
('equipment.create', 'Create equipment', 'equipment'),
|
||||
('equipment.edit', 'Edit equipment', 'equipment'),
|
||||
('equipment.delete', 'Delete equipment', 'equipment'),
|
||||
# Machines
|
||||
('machines.view', 'View machines', 'machines'),
|
||||
('machines.create', 'Create machines', 'machines'),
|
||||
('machines.edit', 'Edit machines', 'machines'),
|
||||
('machines.delete', 'Delete machines', 'machines'),
|
||||
# Computers
|
||||
('computers.view', 'View computers', 'computers'),
|
||||
('computers.create', 'Create computers', 'computers'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Shared Alembic env.py logic for bundled plugins.
|
||||
|
||||
Every bundled plugin that owns tables (computers, employees, equipment,
|
||||
knowledgebase, network, notifications, printers, slides, usb, warranty) has a
|
||||
Every bundled plugin that owns tables (computers, employees, knowledgebase,
|
||||
machines, network, notifications, printers, slides, usb, warranty) has a
|
||||
`migrations/env.py` that does the minimum:
|
||||
|
||||
import os
|
||||
@@ -42,8 +42,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'computers': ('computertypes', 'computers', 'computerinstalledapps',
|
||||
'accessprotocols', 'computeraccess'),
|
||||
'employees': ('directoryemployees',),
|
||||
'equipment': ('equipmenttypes', 'equipment'),
|
||||
'knowledgebase': ('knowledgebase',),
|
||||
'machines': ('machinetypes', 'machines'),
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
||||
'notifications': ('notificationtypes', 'notifications'),
|
||||
|
||||
@@ -41,6 +41,24 @@ class PluginRegistry:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Corrupted file, start fresh
|
||||
self._plugins = {}
|
||||
self._migrate_renamed_plugins()
|
||||
|
||||
def _migrate_renamed_plugins(self) -> None:
|
||||
"""Upgrade path for the equipment -> machines plugin rename.
|
||||
|
||||
Existing installs carry an 'equipment' entry in plugins.json. When the
|
||||
renamed plugins/machines directory exists and no 'machines' entry does,
|
||||
carry the state over under the new name and persist.
|
||||
"""
|
||||
if 'equipment' not in self._plugins or 'machines' in self._plugins:
|
||||
return
|
||||
machines_dir = Path(__file__).resolve().parents[2] / 'plugins' / 'machines'
|
||||
if not machines_dir.exists():
|
||||
return
|
||||
state = self._plugins.pop('equipment')
|
||||
state.name = 'machines'
|
||||
self._plugins['machines'] = state
|
||||
self._save()
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save registry to file."""
|
||||
|
||||
Reference in New Issue
Block a user