Symmetric relationship types (isdirectional flag, migration 7d19) show
one entry per peer on the relationships card - a Dualpath pair no
longer lists its partner twice - and directional types read naturally
instead of Outgoing/Incoming. Deleting a collapsed entry removes every
underlying direction row.
Propagation is now real (migration 7d20): relationship types declare
propagation-through pairs in relationshiptypepropagations (M:N,
replacing the never-consumed single column); creating a controls link
on either bay of a Dualpath pair auto-creates it on the partner,
mirrored across both endpoints because live data stores controls as
bay -> PC. flask relationships propagate backfills existing data (29
rows fanned out on the WJ dataset, idempotent).
This also completes the tree that commit 1d21bf0 accidentally split
(core/models/__init__ imported RelationshipTypePropagation ahead of the
file that defines it), returning CI to green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1126 lines
40 KiB
Python
1126 lines
40 KiB
Python
"""Assets API endpoints - unified asset queries."""
|
|
|
|
from flask import Blueprint, request
|
|
from flask_jwt_extended import jwt_required
|
|
from sqlalchemy.orm import joinedload, subqueryload
|
|
|
|
from shopdb.extensions import db
|
|
from shopdb.core.models import Asset, AssetType, AssetStatus, AssetRelationship, RelationshipType
|
|
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
|
|
from shopdb.utils.import_mode import apply_import_timestamps
|
|
|
|
assets_bp = Blueprint('assets', __name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Asset Types
|
|
# =============================================================================
|
|
|
|
@assets_bp.route('/types', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_asset_types():
|
|
"""List all asset types."""
|
|
page, per_page = get_pagination_params(request)
|
|
|
|
query = AssetType.query
|
|
|
|
if request.args.get('active', 'true').lower() != 'false':
|
|
query = query.filter(AssetType.isactive == True)
|
|
|
|
query = query.order_by(AssetType.assettype)
|
|
|
|
items, total = paginate_query(query, page, per_page)
|
|
data = [t.to_dict() for t in items]
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@assets_bp.route('/types/<int:type_id>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_asset_type(type_id: int):
|
|
"""Get a single asset type."""
|
|
t = db.session.get(AssetType, type_id)
|
|
|
|
if not t:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Asset type with ID {type_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
return success_response(t.to_dict())
|
|
|
|
|
|
@assets_bp.route('/types', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('assets.create')
|
|
def create_asset_type():
|
|
"""Create a new asset type."""
|
|
data = request.get_json()
|
|
|
|
if not data or not data.get('assettype'):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'assettype is required')
|
|
|
|
if AssetType.query.filter_by(assettype=data['assettype']).first():
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f"Asset type '{data['assettype']}' already exists",
|
|
http_code=409
|
|
)
|
|
|
|
t = AssetType(
|
|
assettype=data['assettype'],
|
|
pluginname=data.get('pluginname'),
|
|
tablename=data.get('tablename'),
|
|
description=data.get('description'),
|
|
icon=data.get('icon'),
|
|
color=data.get('color')
|
|
)
|
|
|
|
db.session.add(t)
|
|
db.session.commit()
|
|
|
|
return success_response(t.to_dict(), message='Asset type created', http_code=201)
|
|
|
|
|
|
@assets_bp.route('/types/<int:type_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('assets.edit')
|
|
def update_asset_type(type_id: int):
|
|
"""Update an asset type's display fields (color/icon/description). The name,
|
|
plugin, and table are structural and not editable here."""
|
|
t = db.session.get(AssetType, type_id)
|
|
if not t:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset type not found', http_code=404)
|
|
data = request.get_json() or {}
|
|
for key in ('description', 'icon', 'color', 'isactive'):
|
|
if key in data:
|
|
setattr(t, key, data[key])
|
|
db.session.commit()
|
|
return success_response(t.to_dict(), message='Asset type updated')
|
|
|
|
|
|
# =============================================================================
|
|
# Asset Statuses
|
|
# =============================================================================
|
|
|
|
@assets_bp.route('/statuses', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_asset_statuses():
|
|
"""List all asset statuses."""
|
|
page, per_page = get_pagination_params(request)
|
|
|
|
query = AssetStatus.query
|
|
|
|
if request.args.get('active', 'true').lower() != 'false':
|
|
query = query.filter(AssetStatus.isactive == True)
|
|
|
|
query = query.order_by(AssetStatus.status)
|
|
|
|
items, total = paginate_query(query, page, per_page)
|
|
data = [s.to_dict() for s in items]
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@assets_bp.route('/statuses/<int:status_id>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_asset_status(status_id: int):
|
|
"""Get a single asset status."""
|
|
s = db.session.get(AssetStatus, status_id)
|
|
|
|
if not s:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Asset status with ID {status_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
return success_response(s.to_dict())
|
|
|
|
|
|
@assets_bp.route('/statuses', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('assets.create')
|
|
def create_asset_status():
|
|
"""Create a new asset status."""
|
|
data = request.get_json()
|
|
|
|
if not data or not data.get('status'):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required')
|
|
|
|
if AssetStatus.query.filter_by(status=data['status']).first():
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f"Asset status '{data['status']}' already exists",
|
|
http_code=409
|
|
)
|
|
|
|
s = AssetStatus(
|
|
status=data['status'],
|
|
description=data.get('description'),
|
|
color=data.get('color')
|
|
)
|
|
|
|
db.session.add(s)
|
|
db.session.commit()
|
|
|
|
return success_response(s.to_dict(), message='Asset status created', http_code=201)
|
|
|
|
|
|
@assets_bp.route('/statuses/<int:status_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('assets.edit')
|
|
def update_asset_status(status_id: int):
|
|
"""Update an asset status."""
|
|
s = db.session.get(AssetStatus, status_id)
|
|
if not s:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found',
|
|
http_code=404)
|
|
|
|
data = request.get_json() or {}
|
|
|
|
# Conflict check on rename
|
|
if 'status' in data and data['status'] != s.status:
|
|
if AssetStatus.query.filter_by(status=data['status']).first():
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f"Asset status '{data['status']}' already exists",
|
|
http_code=409
|
|
)
|
|
|
|
for key in ('status', 'description', 'color', 'isactive'):
|
|
if key in data:
|
|
setattr(s, key, data[key])
|
|
|
|
db.session.commit()
|
|
return success_response(s.to_dict(), message='Asset status updated')
|
|
|
|
|
|
@assets_bp.route('/statuses/<int:status_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('assets.delete')
|
|
def delete_asset_status(status_id: int):
|
|
"""Delete an asset status. Refused if any asset still uses it."""
|
|
s = db.session.get(AssetStatus, status_id)
|
|
if not s:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found',
|
|
http_code=404)
|
|
|
|
inuse = Asset.query.filter_by(statusid=status_id).count()
|
|
if inuse:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f"Cannot delete: {inuse} asset(s) still use this status",
|
|
http_code=409
|
|
)
|
|
|
|
db.session.delete(s)
|
|
db.session.commit()
|
|
return success_response(message='Asset status deleted')
|
|
|
|
|
|
# =============================================================================
|
|
# Relationship Types
|
|
# =============================================================================
|
|
|
|
@assets_bp.route('/relationshiptypes', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_relationship_types():
|
|
"""List all asset relationship types."""
|
|
types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
|
|
return success_response([_rel_type_dict(t) for t in types])
|
|
|
|
|
|
@assets_bp.route('/relationshiptypes', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('assets.create')
|
|
def create_relationship_type():
|
|
"""Create a new asset relationship type."""
|
|
data = request.get_json()
|
|
if not data or not data.get('relationshiptype'):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required')
|
|
|
|
if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first():
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f"Relationship type '{data['relationshiptype']}' already exists",
|
|
http_code=409
|
|
)
|
|
|
|
rel_type = RelationshipType(
|
|
relationshiptype=data['relationshiptype'],
|
|
description=data.get('description'),
|
|
color=data.get('color'),
|
|
isdirectional=data.get('isdirectional', True)
|
|
)
|
|
db.session.add(rel_type)
|
|
db.session.commit()
|
|
|
|
return success_response(_rel_type_dict(rel_type), message='Relationship type created', http_code=201)
|
|
|
|
|
|
def _rel_type_dict(t):
|
|
return {
|
|
'relationshiptypeid': t.relationshiptypeid,
|
|
'relationshiptype': t.relationshiptype,
|
|
'description': t.description,
|
|
'color': t.color,
|
|
'isdirectional': t.isdirectional,
|
|
# read-only view of the propagation rails (seed/CLI-managed, no UI)
|
|
'propagatesthrough': [
|
|
{
|
|
'relationshiptypeid': p.relationshiptypeid,
|
|
'relationshiptype': p.relationshiptype,
|
|
'isdirectional': p.isdirectional,
|
|
}
|
|
for p in t.propagatesthrough
|
|
],
|
|
}
|
|
|
|
|
|
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('assets.edit')
|
|
def update_relationship_type(type_id: int):
|
|
"""Update a relationship type."""
|
|
t = db.session.get(RelationshipType, type_id)
|
|
if not t:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404)
|
|
data = request.get_json() or {}
|
|
if 'relationshiptype' in data and data['relationshiptype'] != t.relationshiptype:
|
|
if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first():
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
f"Relationship type '{data['relationshiptype']}' already exists", http_code=409)
|
|
for key in ('relationshiptype', 'description', 'color', 'isdirectional'):
|
|
if key in data:
|
|
setattr(t, key, data[key])
|
|
db.session.commit()
|
|
return success_response(_rel_type_dict(t), message='Relationship type updated')
|
|
|
|
|
|
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('assets.delete')
|
|
def delete_relationship_type(type_id: int):
|
|
"""Delete a relationship type. Refused if any relationship still uses it."""
|
|
t = db.session.get(RelationshipType, type_id)
|
|
if not t:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404)
|
|
inuse = AssetRelationship.query.filter_by(relationshiptypeid=type_id).count()
|
|
if inuse:
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
f"Cannot delete: {inuse} relationship(s) still use this type", http_code=409)
|
|
db.session.delete(t)
|
|
db.session.commit()
|
|
return success_response(message='Relationship type deleted')
|
|
|
|
|
|
# =============================================================================
|
|
# Assets
|
|
# =============================================================================
|
|
|
|
@assets_bp.route('', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_assets():
|
|
"""
|
|
List all assets with filtering and pagination.
|
|
|
|
Query parameters:
|
|
- page: Page number (default: 1)
|
|
- 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., 'machine', 'computer')
|
|
- type_id: Filter by asset type ID
|
|
- status_id: Filter by status ID
|
|
- location_id: Filter by location ID
|
|
- businessunit_id: Filter by business unit ID
|
|
- include_type_data: Include category-specific extension data (default: false)
|
|
"""
|
|
page, per_page = get_pagination_params(request)
|
|
|
|
query = Asset.query
|
|
|
|
# Active filter
|
|
if request.args.get('active', 'true').lower() != 'false':
|
|
query = query.filter(Asset.isactive == True)
|
|
|
|
# Search filter
|
|
if search := request.args.get('search'):
|
|
query = query.filter(
|
|
db.or_(
|
|
Asset.assetnumber.ilike(f'%{search}%'),
|
|
Asset.name.ilike(f'%{search}%'),
|
|
Asset.serialnumber.ilike(f'%{search}%')
|
|
)
|
|
)
|
|
|
|
# Type filter by name
|
|
if type_name := request.args.get('type'):
|
|
query = query.join(AssetType).filter(AssetType.assettype == type_name)
|
|
|
|
# Type filter by ID
|
|
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
|
query = query.filter(Asset.assettypeid == int(type_id))
|
|
|
|
# Status filter
|
|
if status_id := request.args.get('statusid', request.args.get('status_id')):
|
|
query = query.filter(Asset.statusid == int(status_id))
|
|
|
|
# Location filter
|
|
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
|
query = query.filter(Asset.locationid == int(location_id))
|
|
|
|
# Business unit filter
|
|
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
|
query = query.filter(Asset.businessunitid == int(bu_id))
|
|
|
|
# Sorting
|
|
sort_by = request.args.get('sort', 'assetnumber')
|
|
sort_dir = request.args.get('dir', 'asc')
|
|
|
|
sort_columns = {
|
|
'assetnumber': Asset.assetnumber,
|
|
'name': Asset.name,
|
|
'createddate': Asset.createddate,
|
|
'modifieddate': Asset.modifieddate,
|
|
}
|
|
|
|
if sort_by in sort_columns:
|
|
col = sort_columns[sort_by]
|
|
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
|
|
else:
|
|
query = query.order_by(Asset.assetnumber)
|
|
|
|
items, total = paginate_query(query, page, per_page)
|
|
|
|
# Include type data if requested
|
|
include_type_data = request.args.get('include_type_data', 'false').lower() == 'true'
|
|
data = [a.to_dict(include_type_data=include_type_data) for a in items]
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@assets_bp.route('/<int:asset_id>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_asset(asset_id: int):
|
|
"""
|
|
Get a single asset with full details.
|
|
|
|
Query parameters:
|
|
- include_type_data: Include category-specific extension data (default: true)
|
|
"""
|
|
asset = db.session.get(Asset, asset_id)
|
|
|
|
if not asset:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Asset with ID {asset_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
include_type_data = request.args.get('include_type_data', 'true').lower() != 'false'
|
|
return success_response(asset.to_dict(include_type_data=include_type_data))
|
|
|
|
|
|
@assets_bp.route('', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('assets.create')
|
|
def create_asset():
|
|
"""Create a new asset."""
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
|
|
|
# Validate required fields
|
|
if not data.get('assetnumber'):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
|
|
if not data.get('assettypeid'):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid is required')
|
|
|
|
# Check for duplicate assetnumber
|
|
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f"Asset with number '{data['assetnumber']}' already exists",
|
|
http_code=409
|
|
)
|
|
|
|
# Validate foreign keys exist
|
|
if not db.session.get(AssetType, data['assettypeid']):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
f"Asset type with ID {data['assettypeid']} not found"
|
|
)
|
|
|
|
asset = Asset(
|
|
assetnumber=data['assetnumber'],
|
|
name=data.get('name'),
|
|
serialnumber=data.get('serialnumber'),
|
|
assettypeid=data['assettypeid'],
|
|
statusid=data.get('statusid', 1),
|
|
locationid=data.get('locationid'),
|
|
businessunitid=data.get('businessunitid'),
|
|
mapx=data.get('mapx'),
|
|
mapy=data.get('mapy'),
|
|
notes=data.get('notes')
|
|
)
|
|
|
|
db.session.add(asset)
|
|
apply_import_timestamps(asset, data)
|
|
db.session.commit()
|
|
|
|
return success_response(asset.to_dict(), message='Asset created', http_code=201)
|
|
|
|
|
|
@assets_bp.route('/<int:asset_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
@require_permission('assets.edit')
|
|
def update_asset(asset_id: int):
|
|
"""Update an asset."""
|
|
asset = db.session.get(Asset, asset_id)
|
|
|
|
if not asset:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Asset with ID {asset_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
data = request.get_json()
|
|
if not data:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
|
|
|
# Check for conflicting assetnumber
|
|
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
|
|
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f"Asset with number '{data['assetnumber']}' already exists",
|
|
http_code=409
|
|
)
|
|
|
|
# Update allowed fields
|
|
allowed_fields = [
|
|
'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
|
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'
|
|
]
|
|
|
|
for key in allowed_fields:
|
|
if key in data:
|
|
setattr(asset, key, data[key])
|
|
|
|
apply_import_timestamps(asset, data)
|
|
db.session.commit()
|
|
return success_response(asset.to_dict(), message='Asset updated')
|
|
|
|
|
|
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('assets.delete')
|
|
def delete_asset(asset_id: int):
|
|
"""Delete (soft delete) an asset."""
|
|
asset = db.session.get(Asset, asset_id)
|
|
|
|
if not asset:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Asset with ID {asset_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
asset.isactive = False
|
|
db.session.commit()
|
|
|
|
return success_response(message='Asset deleted')
|
|
|
|
|
|
@assets_bp.route('/lookup/<assetnumber>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def lookup_asset_by_number(assetnumber: str):
|
|
"""
|
|
Look up an asset by its asset number.
|
|
|
|
Useful for finding the asset ID when you only have the machine/asset number.
|
|
"""
|
|
asset = Asset.query.filter_by(assetnumber=assetnumber, isactive=True).first()
|
|
|
|
if not asset:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Asset with number {assetnumber} not found',
|
|
http_code=404
|
|
)
|
|
|
|
return success_response(asset.to_dict(include_type_data=True))
|
|
|
|
|
|
# =============================================================================
|
|
# Asset Relationships
|
|
# =============================================================================
|
|
|
|
def _partners_via(assetid, throughtypeid):
|
|
"""Return the set of asset ids linked to assetid via throughtypeid.
|
|
|
|
Direction-blind: matches rows where assetid is source OR target, so a
|
|
symmetric through-type (Dualpath) stored in either direction is caught.
|
|
"""
|
|
rows = AssetRelationship.query.filter(
|
|
AssetRelationship.relationshiptypeid == throughtypeid,
|
|
AssetRelationship.isactive == True,
|
|
db.or_(
|
|
AssetRelationship.sourceassetid == assetid,
|
|
AssetRelationship.targetassetid == assetid,
|
|
),
|
|
).all()
|
|
partners = set()
|
|
for row in rows:
|
|
other = row.targetassetid if row.sourceassetid == assetid else row.sourceassetid
|
|
partners.add(other)
|
|
return partners
|
|
|
|
|
|
def _relationship_exists(sourceid, targetid, typeid):
|
|
# existence check ignores isactive so a soft-deleted row still blocks a
|
|
# duplicate insert (the uniqueness constraint spans all rows).
|
|
return AssetRelationship.query.filter_by(
|
|
sourceassetid=sourceid,
|
|
targetassetid=targetid,
|
|
relationshiptypeid=typeid,
|
|
).first() is not None
|
|
|
|
|
|
def _add_propagated(sourceid, targetid, rel, created):
|
|
# idempotent add; skip if source-T->target already exists (any isactive)
|
|
if sourceid in (targetid,):
|
|
return
|
|
if _relationship_exists(sourceid, targetid, rel.relationshiptypeid):
|
|
return
|
|
newrel = AssetRelationship(
|
|
sourceassetid=sourceid,
|
|
targetassetid=targetid,
|
|
relationshiptypeid=rel.relationshiptypeid,
|
|
label=rel.label,
|
|
)
|
|
db.session.add(newrel)
|
|
created.append(newrel)
|
|
|
|
|
|
def propagate_relationship(rel):
|
|
"""Fan out rel across the SYMMETRIC through-types of its type.
|
|
|
|
A Dualpath pair is one physical dual-bay machine, so a relationship that
|
|
touches one bay must be mirrored to the partner bay - whichever end (source
|
|
or target) carries the Dualpath link. For each symmetric through-type P
|
|
(isdirectional false) in the type's propagation set we mirror BOTH ends,
|
|
keeping the relationship's direction:
|
|
|
|
* for each partner X of the TARGET via P: create source -T-> X
|
|
* for each partner X of the SOURCE via P: create X -T-> target
|
|
|
|
In practice only one end has partners (the bay end of a controls edge,
|
|
whichever direction the row is stored), so there is no double fan-out.
|
|
Canonical controls direction is PC -> machine (the PC is the controller:
|
|
it sends programs to the machine and receives logs); reversed legacy rows
|
|
are corrected by `flask relationships fix-controls-direction`, and
|
|
propagation preserves whatever direction the row carries. Same-direction
|
|
idempotency: controls is directional, so we skip when the same
|
|
source-T->target row already exists. Returns the newly created
|
|
AssetRelationship rows (added to the session, not committed).
|
|
|
|
Directional through-types (partof) are declared but NOT consumed: the
|
|
parent/child fan-out direction is ambiguous, so their propagation semantics
|
|
are deferred and partof stays inert exactly as before.
|
|
"""
|
|
created = []
|
|
reltype = rel.relationshiptype or db.session.get(RelationshipType, rel.relationshiptypeid)
|
|
if not reltype:
|
|
return created
|
|
for through in reltype.propagatesthrough:
|
|
if through.isdirectional:
|
|
continue # directional propagation deferred (parent/child ambiguity)
|
|
# target-side partners inherit being controlled BY the source
|
|
for partnerid in _partners_via(rel.targetassetid, through.relationshiptypeid):
|
|
if partnerid != rel.sourceassetid:
|
|
_add_propagated(rel.sourceassetid, partnerid, rel, created)
|
|
# source-side partners inherit controlling the target
|
|
for partnerid in _partners_via(rel.sourceassetid, through.relationshiptypeid):
|
|
if partnerid != rel.targetassetid:
|
|
_add_propagated(partnerid, rel.targetassetid, rel, created)
|
|
return created
|
|
|
|
|
|
@assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_asset_relationships(asset_id: int):
|
|
"""
|
|
Get all relationships for an asset.
|
|
|
|
Returns both outgoing (source) and incoming (target) relationships.
|
|
"""
|
|
asset = db.session.get(Asset, asset_id)
|
|
|
|
if not asset:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Asset with ID {asset_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
# Get outgoing relationships (this asset is source)
|
|
outgoing = AssetRelationship.query.filter_by(
|
|
sourceassetid=asset_id
|
|
).filter(AssetRelationship.isactive == True).all()
|
|
|
|
# Get incoming relationships (this asset is target)
|
|
incoming = AssetRelationship.query.filter_by(
|
|
targetassetid=asset_id
|
|
).filter(AssetRelationship.isactive == True).all()
|
|
|
|
outgoing_data = []
|
|
for rel in outgoing:
|
|
r = rel.to_dict()
|
|
r['targetasset'] = rel.targetasset.to_dict() if rel.targetasset else None
|
|
r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None
|
|
# directionality drives card display (symmetric collapse vs arrows)
|
|
r['isdirectional'] = rel.relationshiptype.isdirectional if rel.relationshiptype else True
|
|
outgoing_data.append(r)
|
|
|
|
incoming_data = []
|
|
for rel in incoming:
|
|
r = rel.to_dict()
|
|
r['sourceasset'] = rel.sourceasset.to_dict() if rel.sourceasset else None
|
|
r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None
|
|
r['isdirectional'] = rel.relationshiptype.isdirectional if rel.relationshiptype else True
|
|
incoming_data.append(r)
|
|
|
|
return success_response({
|
|
'outgoing': outgoing_data,
|
|
'incoming': incoming_data
|
|
})
|
|
|
|
|
|
@assets_bp.route('/relationships', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('assets.create')
|
|
def create_asset_relationship():
|
|
"""Create a relationship between two assets.
|
|
|
|
After the row is created, the relationship is fanned out across its type's
|
|
propagation rails (relationshiptypepropagations). For each SYMMETRIC
|
|
through-type (e.g. Dualpath) the edge is mirrored to the partner of
|
|
whichever endpoint carries the through-type link, keeping direction.
|
|
Concretely, `controls` between a bay of a dual-bay machine and its PC is
|
|
also created for the partner bay, since a Dualpath pair is one physical
|
|
machine with a single controller. Directional through-types (partof) are
|
|
declared but not consumed yet. The response carries `propagated` and
|
|
`propagatedcount`. Propagation pairs are seed/CLI-managed (there is no
|
|
management UI); backfill legacy data with `flask relationships propagate`.
|
|
"""
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
|
|
|
# Validate required fields
|
|
required = ['sourceassetid', 'targetassetid', 'relationshiptypeid']
|
|
for field in required:
|
|
if not data.get(field):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, f'{field} is required')
|
|
|
|
source_id = data['sourceassetid']
|
|
target_id = data['targetassetid']
|
|
type_id = data['relationshiptypeid']
|
|
|
|
# Validate assets exist
|
|
if not db.session.get(Asset, source_id):
|
|
return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404)
|
|
if not db.session.get(Asset, target_id):
|
|
return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404)
|
|
if not db.session.get(RelationshipType, type_id):
|
|
return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404)
|
|
|
|
# Check for duplicate relationship
|
|
existing = AssetRelationship.query.filter_by(
|
|
sourceassetid=source_id,
|
|
targetassetid=target_id,
|
|
relationshiptypeid=type_id
|
|
).first()
|
|
|
|
if existing:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
'This relationship already exists',
|
|
http_code=409
|
|
)
|
|
|
|
rel = AssetRelationship(
|
|
sourceassetid=source_id,
|
|
targetassetid=target_id,
|
|
relationshiptypeid=type_id,
|
|
notes=data.get('notes')
|
|
)
|
|
|
|
db.session.add(rel)
|
|
apply_import_timestamps(rel, data)
|
|
db.session.flush()
|
|
|
|
# fan out across symmetric propagation rails (e.g. Dualpath partner bays)
|
|
propagated = propagate_relationship(rel)
|
|
for prop in propagated:
|
|
apply_import_timestamps(prop, data)
|
|
db.session.commit()
|
|
|
|
payload = rel.to_dict()
|
|
payload['propagated'] = [prop.to_dict() for prop in propagated]
|
|
payload['propagatedcount'] = len(propagated)
|
|
return success_response(payload, message='Relationship created', http_code=201)
|
|
|
|
|
|
@assets_bp.route('/relationships/<int:rel_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_permission('assets.delete')
|
|
def delete_asset_relationship(rel_id: int):
|
|
"""Delete an asset relationship.
|
|
|
|
Deletion is manual and per-row: propagated rows (e.g. the mirrored controls
|
|
link on a Dualpath partner bay) are real independent rows and each is
|
|
deleted on its own. Removing one bay's link does NOT cascade to the other.
|
|
"""
|
|
rel = db.session.get(AssetRelationship, rel_id)
|
|
|
|
if not rel:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Relationship with ID {rel_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
rel.isactive = False
|
|
db.session.commit()
|
|
|
|
return success_response(message='Relationship deleted')
|
|
|
|
|
|
# =============================================================================
|
|
# Asset Communications
|
|
# =============================================================================
|
|
|
|
# =============================================================================
|
|
# Unified Asset Map
|
|
# =============================================================================
|
|
|
|
@assets_bp.route('/map', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_assets_map():
|
|
"""
|
|
Get all assets with map positions for unified floor map display.
|
|
|
|
Returns assets with mapx/mapy coordinates, joined with type-specific data.
|
|
|
|
Query parameters:
|
|
- 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
|
|
- search: Search by assetnumber, name, or serialnumber
|
|
"""
|
|
from shopdb.core.models import Location, BusinessUnit, Communication
|
|
|
|
# Eager-load all relationships to avoid N+1 queries.
|
|
# Core relationships via joinedload, extension tables via subqueryload
|
|
# with their nested relationships (vendor, model, type) also eager-loaded.
|
|
eager_options = [
|
|
joinedload(Asset.assettype),
|
|
joinedload(Asset.status),
|
|
joinedload(Asset.location),
|
|
joinedload(Asset.businessunit),
|
|
]
|
|
|
|
# Eager-load plugin extension tables AND their relationships
|
|
try:
|
|
from plugins.machines.models import Machine
|
|
eager_options.append(
|
|
subqueryload(Asset.machine)
|
|
.joinedload(Machine.machinetype)
|
|
)
|
|
eager_options.append(
|
|
subqueryload(Asset.machine)
|
|
.joinedload(Machine.vendor)
|
|
)
|
|
eager_options.append(
|
|
subqueryload(Asset.machine)
|
|
.joinedload(Machine.model)
|
|
)
|
|
eager_options.append(
|
|
subqueryload(Asset.machine)
|
|
.joinedload(Machine.controllervendor)
|
|
)
|
|
eager_options.append(
|
|
subqueryload(Asset.machine)
|
|
.joinedload(Machine.controllermodel)
|
|
)
|
|
except (ImportError, AttributeError):
|
|
pass
|
|
try:
|
|
from plugins.computers.models import Computer
|
|
eager_options.append(
|
|
subqueryload(Asset.computer)
|
|
.joinedload(Computer.computertype)
|
|
)
|
|
eager_options.append(
|
|
subqueryload(Asset.computer)
|
|
.joinedload(Computer.operatingsystem)
|
|
)
|
|
except (ImportError, AttributeError):
|
|
pass
|
|
try:
|
|
from plugins.network.models import NetworkDevice
|
|
eager_options.append(
|
|
subqueryload(Asset.network_device)
|
|
.joinedload(NetworkDevice.networkdevicetype)
|
|
)
|
|
eager_options.append(
|
|
subqueryload(Asset.network_device)
|
|
.joinedload(NetworkDevice.vendor)
|
|
)
|
|
except (ImportError, AttributeError):
|
|
pass
|
|
try:
|
|
from plugins.printers.models import Printer
|
|
eager_options.append(
|
|
subqueryload(Asset.printer)
|
|
.joinedload(Printer.printertype)
|
|
)
|
|
eager_options.append(
|
|
subqueryload(Asset.printer)
|
|
.joinedload(Printer.vendor)
|
|
)
|
|
eager_options.append(
|
|
subqueryload(Asset.printer)
|
|
.joinedload(Printer.model)
|
|
)
|
|
except (ImportError, AttributeError):
|
|
pass
|
|
|
|
query = Asset.query.options(*eager_options).filter(
|
|
Asset.isactive == True,
|
|
Asset.mapx.isnot(None),
|
|
Asset.mapy.isnot(None)
|
|
)
|
|
|
|
selected_assettype = request.args.get('assettype')
|
|
|
|
# Filter by asset type name
|
|
if selected_assettype:
|
|
query = query.join(AssetType).filter(AssetType.assettype == selected_assettype)
|
|
|
|
# Filter by subtype (depends on asset type) - case-insensitive matching
|
|
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 == 'machine':
|
|
try:
|
|
from plugins.machines.models import Machine
|
|
query = query.join(Machine, Machine.assetid == Asset.assetid).filter(
|
|
Machine.machinetypeid == subtype_id
|
|
)
|
|
except ImportError:
|
|
pass
|
|
elif asset_type_lower == 'computer':
|
|
try:
|
|
from plugins.computers.models import Computer
|
|
query = query.join(Computer, Computer.assetid == Asset.assetid).filter(
|
|
Computer.computertypeid == subtype_id
|
|
)
|
|
except ImportError:
|
|
pass
|
|
elif asset_type_lower == 'network device':
|
|
try:
|
|
from plugins.network.models import NetworkDevice
|
|
query = query.join(NetworkDevice, NetworkDevice.assetid == Asset.assetid).filter(
|
|
NetworkDevice.networkdevicetypeid == subtype_id
|
|
)
|
|
except ImportError:
|
|
pass
|
|
elif asset_type_lower == 'printer':
|
|
try:
|
|
from plugins.printers.models import Printer
|
|
query = query.join(Printer, Printer.assetid == Asset.assetid).filter(
|
|
Printer.printertypeid == subtype_id
|
|
)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Filter by business unit
|
|
if bu_id := request.args.get('businessunitid'):
|
|
query = query.filter(Asset.businessunitid == int(bu_id))
|
|
|
|
# Filter by status
|
|
if status_id := request.args.get('statusid'):
|
|
query = query.filter(Asset.statusid == int(status_id))
|
|
|
|
# Filter by location
|
|
if location_id := request.args.get('locationid'):
|
|
query = query.filter(Asset.locationid == int(location_id))
|
|
|
|
# Search filter
|
|
if search := request.args.get('search'):
|
|
query = query.filter(
|
|
db.or_(
|
|
Asset.assetnumber.ilike(f'%{search}%'),
|
|
Asset.name.ilike(f'%{search}%'),
|
|
Asset.serialnumber.ilike(f'%{search}%')
|
|
)
|
|
)
|
|
|
|
assets = query.all()
|
|
|
|
# Batch-load primary IPs in a single query instead of N+1 per asset.
|
|
# Prefer isprimary=True IP, fall back to any IP (comtypeid=1).
|
|
asset_ids = [a.assetid for a in assets]
|
|
primary_ip_map = {}
|
|
if asset_ids:
|
|
ip_rows = db.session.query(
|
|
Communication.assetid,
|
|
Communication.ipaddress,
|
|
Communication.isprimary
|
|
).filter(
|
|
Communication.assetid.in_(asset_ids),
|
|
Communication.comtypeid == 1,
|
|
Communication.isactive == True
|
|
).order_by(
|
|
Communication.isprimary.desc()
|
|
).all()
|
|
|
|
for row in ip_rows:
|
|
# First match wins (isprimary=True sorted first)
|
|
if row.assetid not in primary_ip_map:
|
|
primary_ip_map[row.assetid] = row.ipaddress
|
|
|
|
# Build response - all relationship data is already loaded, no extra queries
|
|
data = []
|
|
for asset in assets:
|
|
item = {
|
|
'assetid': asset.assetid,
|
|
'assetnumber': asset.assetnumber,
|
|
'name': asset.name,
|
|
'displayname': asset.display_name,
|
|
'serialnumber': asset.serialnumber,
|
|
'mapx': asset.mapx,
|
|
'mapy': asset.mapy,
|
|
'assettype': asset.assettype.assettype if asset.assettype else None,
|
|
'assettypeid': asset.assettypeid,
|
|
'status': asset.status.status if asset.status else None,
|
|
'statusid': asset.statusid,
|
|
'statuscolor': asset.status.color if asset.status else None,
|
|
'location': asset.location.locationname if asset.location else None,
|
|
'locationid': asset.locationid,
|
|
'businessunit': asset.businessunit.businessunit if asset.businessunit else None,
|
|
'businessunitid': asset.businessunitid,
|
|
'primaryip': primary_ip_map.get(asset.assetid),
|
|
}
|
|
|
|
# Extension data is already eager-loaded via lazy='joined' backrefs
|
|
type_data = asset._get_extension_data()
|
|
if type_data:
|
|
item['typedata'] = type_data
|
|
|
|
data.append(item)
|
|
|
|
# Get filter options - these are small reference tables, no N+1 concern
|
|
asset_types = AssetType.query.filter(AssetType.isactive == True).all()
|
|
types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon, 'color': t.color} for t in asset_types]
|
|
|
|
statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all()
|
|
status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses]
|
|
|
|
business_units = BusinessUnit.query.filter(BusinessUnit.isactive == True).all()
|
|
bu_data = [{'businessunitid': bu.businessunitid, 'businessunit': bu.businessunit} for bu in business_units]
|
|
|
|
locations = Location.query.filter(Location.isactive == True).all()
|
|
loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations]
|
|
|
|
# Get subtypes for filter dropdowns
|
|
subtypes = {}
|
|
|
|
try:
|
|
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['Machine'] = []
|
|
|
|
try:
|
|
from plugins.computers.models import ComputerType
|
|
computer_types = ComputerType.query.filter(ComputerType.isactive == True).order_by(ComputerType.computertype).all()
|
|
subtypes['Computer'] = [{'id': ct.computertypeid, 'name': ct.computertype, 'color': ct.color} for ct in computer_types]
|
|
except ImportError:
|
|
subtypes['Computer'] = []
|
|
|
|
try:
|
|
from plugins.network.models import NetworkDeviceType
|
|
net_types = NetworkDeviceType.query.filter(NetworkDeviceType.isactive == True).order_by(NetworkDeviceType.networkdevicetype).all()
|
|
subtypes['Network Device'] = [{'id': nt.networkdevicetypeid, 'name': nt.networkdevicetype, 'color': nt.color} for nt in net_types]
|
|
except ImportError:
|
|
subtypes['Network Device'] = []
|
|
|
|
try:
|
|
from plugins.printers.models import PrinterType
|
|
printer_types = PrinterType.query.filter(PrinterType.isactive == True).order_by(PrinterType.printertype).all()
|
|
subtypes['Printer'] = [{'id': pt.printertypeid, 'name': pt.printertype, 'color': pt.color} for pt in printer_types]
|
|
except ImportError:
|
|
subtypes['Printer'] = []
|
|
|
|
return success_response({
|
|
'assets': data,
|
|
'total': len(data),
|
|
'filters': {
|
|
'assettypes': types_data,
|
|
'statuses': status_data,
|
|
'businessunits': bu_data,
|
|
'locations': loc_data,
|
|
'subtypes': subtypes
|
|
}
|
|
})
|
|
|
|
|
|
@assets_bp.route('/<int:asset_id>/communications', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_asset_communications(asset_id: int):
|
|
"""Get all communications for an asset."""
|
|
from shopdb.core.models import Communication
|
|
|
|
asset = db.session.get(Asset, asset_id)
|
|
|
|
if not asset:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'Asset with ID {asset_id} not found',
|
|
http_code=404
|
|
)
|
|
|
|
comms = Communication.query.filter_by(
|
|
assetid=asset_id,
|
|
isactive=True
|
|
).all()
|
|
|
|
data = []
|
|
for comm in comms:
|
|
c = comm.to_dict()
|
|
c['comtype_name'] = comm.comtype.comtype if comm.comtype else None
|
|
data.append(c)
|
|
|
|
return success_response(data)
|