Files
shopdb-flask/shopdb/core/api/assets.py
cproudlock b16f143467 search: asset lists search the type column they display
Every asset list shows a Type column (and printers a Model, machines and
network a Vendor), but the search filters only looked at the asset number,
name, serial and hostname. Searching a type returned zero rows: 'Part Washer'
on machines, 'Standard' on PCs, 'Thermal' on printers.

Extend the search on machines, computers, printers, network devices,
measuring tools and the unified asset list to cover the type name plus the
vendor/model where the list shows them. Joins are outer joins so an asset
missing a type or vendor still matches on its own fields; the core list uses
a correlated EXISTS instead, since its type-name filter already joins
AssetType.
2026-07-31 09:02:51 -04:00

1175 lines
42 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
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. 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 := 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
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: hide the secondary bay marker so a
# dual-bay pair shows one marker; the primary carries the partner label.
# Gated on the site setting (default on).
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()
if dualpath_collapse.secondaryassetids:
query = query.filter(
Asset.assetid.notin_(dualpath_collapse.secondaryassetids))
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)
# 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 := 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
# combined-label partner for a collapsed dual-bay pair (label only)
if dualpath_collapse:
partner = dualpath_collapse.partnerbyasset.get(asset.assetid)
item['dualpathpartner'] = partner['assetnumber'] if partner 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'])
@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)