Three map and aggregation defects. The first is a behaviour change you asked for; the other two are the 2026-09-03 run's findings. A dualpath secondary bay was excluded from /api/assets/map unconditionally while the collapse setting is on, so positioning it was a silent no-op: the form took the pin, stored mapx/mapy, and the map never drew it. One marker per pair is right when the pair sits in one place, and that stays the default - but a dual-bay machine whose second bay is physically elsewhere is exactly what positioning it separately means. Now: a secondary with its own position draws its own marker; one without is represented by the primary's, which carries the combined '2007 / 2008' label. Nothing extra is needed for the fallback, because the feed already requires mapx/mapy - an unpositioned bay never reaches the loop. The combined label is now suppressed when the partner draws its own marker. Labelling two separate pins '2007 / 2008' would say one machine is in two places, which is worse than either bay being unlabelled. Verified against a live instance: unpositioned secondary gives one marker labelled with both bays; positioned gives two markers each with its own name; clearing the position puts it back to one. That third case matters - a one-way door would be its own bug. machines/dashboard/summary applied the dualpath exclusion to total and bytype but not to bystatus, so the status bars summed to more than the headline above them, by exactly the number of secondary bays. A breakdown that does not add up to its own total is an afternoon of reconciling. The measuring-tool calibrationstatus filter ran AFTER pagination. It is a derived value so it cannot be a SQL filter, but applying it to the current page meant asking for 'overdue' with one overdue tool in sixteen returned an empty page 1, a total of 16, eight pages, and the row itself on page 3. When that filter is in play the matching set is now built and filtered before paging - the same tradeoff the old comment named, spent on correct answers instead of a cheaper query. Seven tests. The pagination one verified against the committed version, which fails it. Scope: product
1275 lines
48 KiB
Python
1275 lines
48 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.queryargs import intarg
|
|
|
|
from shopdb.utils.authz import require_permission
|
|
from shopdb.utils.import_mode import apply_import_timestamps
|
|
|
|
assets_bp = Blueprint('assets', __name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Asset Types
|
|
# =============================================================================
|
|
|
|
@assets_bp.route('/types', methods=['GET'])
|
|
@require_permission('assets.view')
|
|
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'])
|
|
@require_permission('assets.view')
|
|
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'])
|
|
@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'])
|
|
@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'])
|
|
@require_permission('assets.view')
|
|
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'])
|
|
@require_permission('assets.view')
|
|
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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@require_permission('assets.view')
|
|
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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@require_permission('assets.view')
|
|
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. The asset type is a column in the list, so it has to be
|
|
# searchable. Matched via .has() (a correlated EXISTS) rather than a join,
|
|
# because the type-name filter below joins AssetType itself.
|
|
if search := request.args.get('search'):
|
|
pattern = f'%{search}%'
|
|
query = query.filter(
|
|
db.or_(
|
|
Asset.assetnumber.ilike(pattern),
|
|
Asset.name.ilike(pattern),
|
|
Asset.serialnumber.ilike(pattern),
|
|
Asset.assettype.has(AssetType.assettype.ilike(pattern))
|
|
)
|
|
)
|
|
|
|
# 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 := intarg('typeid', intarg('type_id'))) is not None:
|
|
query = query.filter(Asset.assettypeid == type_id)
|
|
|
|
# Status filter
|
|
if (status_id := intarg('statusid', intarg('status_id'))) is not None:
|
|
query = query.filter(Asset.statusid == status_id)
|
|
|
|
# Location filter
|
|
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
|
query = query.filter(Asset.locationid == location_id)
|
|
|
|
# Business unit filter
|
|
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
|
query = query.filter(Asset.businessunitid == 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'])
|
|
@require_permission('assets.view')
|
|
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'])
|
|
@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'),
|
|
levelid=data.get('levelid'),
|
|
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'])
|
|
@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')
|
|
|
|
# An UPDATE may not blank the asset number. Create validates it and the
|
|
# column is NOT NULL, but the conflict check below only runs when the value
|
|
# DIFFERS, and '' never collides with anything - so a payload carrying an
|
|
# empty assetnumber wrote it straight through. A form that loaded blank
|
|
# (a failed reference load, a partial fetch) then saved the blank over a
|
|
# real record.
|
|
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'assetnumber cannot be empty')
|
|
|
|
# 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', 'levelid', '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'])
|
|
@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'])
|
|
@require_permission('assets.view')
|
|
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
|
|
# Never fan out INTO an existing inverse. The same one-way rule the create
|
|
# path enforces: if target-T->source is already stored, adding source-T->
|
|
# target would manufacture a reciprocal pair that no user asked for, on a
|
|
# rail that is meant to spread one direction across sibling bays.
|
|
if _relationship_exists(targetid, sourceid, 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'])
|
|
@require_permission('assets.view')
|
|
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'])
|
|
@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)
|
|
|
|
# An asset cannot relate to itself. It renders as a duplicate row on that
|
|
# asset's own page (the card lists both ends) and means nothing.
|
|
if source_id == target_id:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'An asset cannot be related to itself'
|
|
)
|
|
|
|
# Check for duplicate relationship.
|
|
#
|
|
# Deleting a relationship is SOFT (isactive = False, see
|
|
# delete_asset_relationship). The row therefore survives, and this lookup
|
|
# found it - so deleting a link and adding it back answered 409 "this
|
|
# relationship already exists" about a link the page no longer shows, with
|
|
# no way forward from the UI at all. The row cannot simply be inserted
|
|
# again either: assetrelationships is unique on (source, target, type).
|
|
#
|
|
# Reactivating IS the create, for an inactive row.
|
|
existing = AssetRelationship.query.filter_by(
|
|
sourceassetid=source_id,
|
|
targetassetid=target_id,
|
|
relationshiptypeid=type_id
|
|
).first()
|
|
|
|
if existing and existing.isactive:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
'This relationship already exists',
|
|
http_code=409
|
|
)
|
|
|
|
# A SINGULAR type allows one active row per source, and setting a new one
|
|
# REPLACES rather than refusing: "make this the default printer" means
|
|
# exactly that, and a card that answered 409 would leave the user to find
|
|
# and delete the old row first.
|
|
#
|
|
# Without this the schema is happy to hold two defaults - the unique
|
|
# constraint is (source, target, type), so two different targets are two
|
|
# valid rows - and the resolver takes the OLDEST, so the new default
|
|
# silently loses.
|
|
reltype = db.session.get(RelationshipType, type_id)
|
|
replaced = 0
|
|
if reltype is not None and getattr(reltype, 'issingular', False):
|
|
others = AssetRelationship.query.filter(
|
|
AssetRelationship.sourceassetid == source_id,
|
|
AssetRelationship.relationshiptypeid == type_id,
|
|
AssetRelationship.isactive == True,
|
|
AssetRelationship.targetassetid != target_id).all()
|
|
for row in others:
|
|
row.isactive = False
|
|
replaced += 1
|
|
|
|
# And it cannot relate BOTH WAYS on a directional type. Only one direction
|
|
# can be true - a PC drives a machine, never the reverse - but the check
|
|
# above is keyed on (source, target, type), so the inverse used to insert
|
|
# cleanly and both rows then rendered on both asset pages. The dialog offers
|
|
# an `incoming` direction, so this is reachable by hand, and the legacy
|
|
# import stores controls the wrong way round.
|
|
#
|
|
# Symmetric types are exempt: they store both directions ON PURPOSE and the
|
|
# card collapses them to one entry per peer.
|
|
reltype = db.session.get(RelationshipType, type_id)
|
|
if reltype is not None and reltype.isdirectional:
|
|
inverse = AssetRelationship.query.filter_by(
|
|
sourceassetid=target_id,
|
|
targetassetid=source_id,
|
|
relationshiptypeid=type_id
|
|
).first()
|
|
# isactive, for the same reason as the duplicate check above: a soft
|
|
# deleted inverse is a link somebody already removed, and blocking on it
|
|
# made "remove the existing one first" - the instruction in this very
|
|
# message - fail to unblock anything.
|
|
if inverse is not None and inverse.isactive:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f"The reverse of this relationship already exists "
|
|
f"(relationship {inverse.relationshipid}). A "
|
|
f"'{reltype.relationshiptype}' link points one way only - "
|
|
f"remove the existing one first if the direction is wrong.",
|
|
http_code=409
|
|
)
|
|
|
|
if existing is not None:
|
|
# The soft-deleted row from the duplicate check. Reuse it: the unique
|
|
# index on (source, target, type) means there is no second row to add.
|
|
rel = existing
|
|
rel.isactive = True
|
|
rel.notes = data.get('notes')
|
|
else:
|
|
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'])
|
|
@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'])
|
|
@require_permission('assets.view')
|
|
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
|
|
try:
|
|
from plugins.measuringtools.models import MeasuringTool
|
|
eager_options.append(
|
|
subqueryload(Asset.measuringtool)
|
|
.joinedload(MeasuringTool.measuringtooltype)
|
|
)
|
|
except (ImportError, AttributeError):
|
|
pass
|
|
|
|
query = Asset.query.options(*eager_options).filter(
|
|
Asset.isactive == True,
|
|
Asset.mapx.isnot(None),
|
|
Asset.mapy.isnot(None)
|
|
)
|
|
|
|
# Dualpath single-machine collapse on the map: a secondary bay draws its
|
|
# OWN marker when somebody has positioned it, and is represented by the
|
|
# primary's marker when nobody has.
|
|
#
|
|
# It used to be excluded unconditionally, which made setting a position on
|
|
# a secondary bay a silent no-op: the form accepted the pin, stored it, and
|
|
# the map never drew it. One marker per pair is right when the pair sits in
|
|
# one place, which is the common case and still the default - but a
|
|
# dual-bay machine whose second bay is physically somewhere else is exactly
|
|
# what positioning it separately means.
|
|
#
|
|
# No extra filtering is needed for the fallback half: the query above
|
|
# already requires mapx/mapy, so an unpositioned secondary never reaches
|
|
# here and the primary's marker stands for both, carrying the combined
|
|
# label.
|
|
from shopdb.core.services.dualpath import (
|
|
resolve_dualpath_pairs, dualpath_single_machine_enabled)
|
|
dualpath_collapse = None
|
|
if dualpath_single_machine_enabled():
|
|
dualpath_collapse = resolve_dualpath_pairs()
|
|
|
|
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 := intarg('subtype')) is not None:
|
|
# Normalize the underscore DB form (measuring_tool, network_device) to
|
|
# the space form the branches below compare against.
|
|
asset_type_lower = (
|
|
selected_assettype.lower().replace('_', ' ') 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
|
|
elif asset_type_lower == 'measuring tool':
|
|
try:
|
|
from plugins.measuringtools.models import MeasuringTool
|
|
query = query.join(
|
|
MeasuringTool, MeasuringTool.assetid == Asset.assetid).filter(
|
|
MeasuringTool.measuringtooltypeid == subtype_id
|
|
)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Filter by business unit
|
|
if (bu_id := intarg('businessunitid')) is not None:
|
|
query = query.filter(Asset.businessunitid == bu_id)
|
|
|
|
# Filter by status
|
|
if (status_id := intarg('statusid')) is not None:
|
|
query = query.filter(Asset.statusid == status_id)
|
|
|
|
# Filter by location
|
|
if (location_id := intarg('locationid')) is not None:
|
|
query = query.filter(Asset.locationid == 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()
|
|
|
|
# Every asset that ends up with a marker. Used to decide whether a dual-bay
|
|
# partner draws its own pin or is represented by this one.
|
|
positioned_assetids = {asset.assetid for asset in assets}
|
|
|
|
# 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,
|
|
'levelid': asset.levelid,
|
|
'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
|
|
|
|
# Combined label for a dual-bay pair, and ONLY when this marker really
|
|
# stands for both bays. A partner that draws its own marker gets its
|
|
# own name: labelling two separate pins '2007 / 2008' would say the
|
|
# same machine is in two places.
|
|
if dualpath_collapse:
|
|
partner = dualpath_collapse.partnerbyasset.get(asset.assetid)
|
|
partner_has_marker = bool(
|
|
partner and partner['assetid'] in positioned_assetids)
|
|
item['dualpathpartner'] = (
|
|
partner['assetnumber'] if partner and not partner_has_marker
|
|
else None)
|
|
|
|
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'] = []
|
|
|
|
try:
|
|
from plugins.measuringtools.models import MeasuringToolType
|
|
measuringtool_types = MeasuringToolType.query.filter(
|
|
MeasuringToolType.isactive == True).order_by(MeasuringToolType.name).all()
|
|
subtypes['Measuring Tool'] = [{'id': mt.measuringtooltypeid, 'name': mt.name, 'color': mt.color} for mt in measuringtool_types]
|
|
except ImportError:
|
|
subtypes['Measuring Tool'] = []
|
|
|
|
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'])
|
|
@require_permission('assets.view')
|
|
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)
|