Add the dualpath-as-single-machine site toggle
Most facilities consider a Dualpath pair one physical dual-bay machine. New site setting dualpath_single_machine (default on): the machines list, dashboard counts, machines-by-type report, and floor map collapse each pair to its primary bay (lower assetnumber), with combined 2007 / 2008 labels; pagination totals stay honest. Detail pages remain per-bay and always show a dual-bay sibling banner linking the partner. Pair resolution lives in core services and joins the plugin contract surface (0.8.0 -> 0.9.0). On the WJ dataset: 31 pairs collapse, machine counts 262 -> 231, map 470 assets. Toggle verified live in both states, left on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,11 @@ from .plugins import plugin_manager
|
||||
# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards,
|
||||
# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the
|
||||
# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump.
|
||||
__contract_version__ = '0.8.0'
|
||||
# 0.9.0: added the dualpath single-machine collapse helpers to shopdb.api
|
||||
# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the
|
||||
# machines plugin list/detail to collapse dual-bay pairs. Two additive names,
|
||||
# minor bump.
|
||||
__contract_version__ = '0.9.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -65,6 +65,12 @@ from shopdb.utils.import_mode import (
|
||||
parse_import_datetime,
|
||||
)
|
||||
|
||||
# Dualpath single-machine collapse (a dual-bay pair is one physical machine)
|
||||
from shopdb.core.services.dualpath import (
|
||||
resolve_dualpath_pairs,
|
||||
dualpath_single_machine_enabled,
|
||||
)
|
||||
|
||||
# Legacy employee directory lookup (read-only) used by notifications
|
||||
from shopdb.utils.employee_db import employee_connection
|
||||
|
||||
@@ -214,6 +220,8 @@ __all__ = [
|
||||
# Helpers
|
||||
'audit_log',
|
||||
'resolve_asset_position',
|
||||
'resolve_dualpath_pairs',
|
||||
'dualpath_single_machine_enabled',
|
||||
# Infrastructure
|
||||
'db',
|
||||
'cache',
|
||||
|
||||
@@ -920,6 +920,18 @@ def get_assets_map():
|
||||
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
|
||||
@@ -1037,6 +1049,11 @@ def get_assets_map():
|
||||
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
|
||||
|
||||
@@ -19,10 +19,21 @@ _TYPE_CATEGORY = {
|
||||
|
||||
|
||||
def _count_by_type(assettype):
|
||||
return db.session.query(Asset).join(AssetType).filter(
|
||||
query = db.session.query(Asset).join(AssetType).filter(
|
||||
Asset.isactive == True,
|
||||
AssetType.assettype == assettype
|
||||
).count()
|
||||
)
|
||||
# Dualpath single-machine collapse: subtract the hidden secondary bays so a
|
||||
# dual-bay machine counts once. Only machines are ever Dualpath-paired, but
|
||||
# the AssetType filter above keeps this correct for any type. Default on.
|
||||
if assettype == 'machine':
|
||||
from shopdb.core.services.dualpath import (
|
||||
resolve_dualpath_pairs, dualpath_single_machine_enabled)
|
||||
if dualpath_single_machine_enabled():
|
||||
secondaryassetids = resolve_dualpath_pairs().secondaryassetids
|
||||
if secondaryassetids:
|
||||
query = query.filter(Asset.assetid.notin_(secondaryassetids))
|
||||
return query.count()
|
||||
|
||||
|
||||
@dashboard_bp.route('/summary', methods=['GET'])
|
||||
|
||||
@@ -64,6 +64,17 @@ def machines_by_type():
|
||||
if bu_id := request.args.get('businessunitid'):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
|
||||
# Dualpath single-machine collapse: exclude the secondary bay so a dual-bay
|
||||
# pair counts once per type bucket. Gated on the site setting (default on).
|
||||
from shopdb.core.services.dualpath import (
|
||||
resolve_dualpath_pairs, dualpath_single_machine_enabled)
|
||||
if dualpath_single_machine_enabled():
|
||||
secondaryassetids = resolve_dualpath_pairs().secondaryassetids
|
||||
if secondaryassetids:
|
||||
query = query.filter(
|
||||
db.or_(Machine.assetid.is_(None),
|
||||
Machine.assetid.notin_(secondaryassetids)))
|
||||
|
||||
query = query.group_by(
|
||||
MachineType.machinetypeid,
|
||||
MachineType.machinetype,
|
||||
|
||||
@@ -418,6 +418,13 @@ def build_default_settings():
|
||||
'category': 'site',
|
||||
'description': 'Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never errors.'
|
||||
},
|
||||
{
|
||||
'key': 'dualpath_single_machine',
|
||||
'value': 'true',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'site',
|
||||
'description': 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. Off = list and count both bays separately.'
|
||||
},
|
||||
{
|
||||
'key': 'printer_hostname_template',
|
||||
'value': 'Printer-{ip}.printer.geaerospace.net',
|
||||
|
||||
116
shopdb/core/services/dualpath.py
Normal file
116
shopdb/core/services/dualpath.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Dualpath single-machine collapse resolution.
|
||||
|
||||
A Dualpath relationship pair is ONE physical dual-bay machine (single
|
||||
controller, one bay-selector switch) recorded as two asset rows. Most
|
||||
facilities want lists, counts, and the map to show that pair as a single
|
||||
machine; the data model always keeps both rows. This module resolves the
|
||||
pairs so the consumers (machines list, dashboard/report counts, floor map)
|
||||
can collapse them, and is exposed via the plugin contract surface
|
||||
(shopdb.api) so the machines plugin can reach it contract-purely.
|
||||
|
||||
PRIMARY = the pair member with the lower natural-sort assetnumber; the other
|
||||
member is SECONDARY and is the one hidden when collapsing. Pairs are derived
|
||||
from active assetrelationships rows whose type is named 'Dualpath' (either
|
||||
direction; mirrored rows in both directions collapse to one pair). Only pairs
|
||||
where BOTH assets are active are resolved.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections import namedtuple
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, AssetRelationship, RelationshipType
|
||||
|
||||
# secondaryassetids: set of the non-primary bay asset ids (hide when collapsing)
|
||||
# partnerbyasset: {assetid -> {'assetid', 'assetnumber'}} for EVERY pair member,
|
||||
# primary and secondary alike, so detail pages can show a sibling
|
||||
# banner from whichever bay you land on.
|
||||
DualpathCollapse = namedtuple('DualpathCollapse', ['secondaryassetids', 'partnerbyasset'])
|
||||
|
||||
DUALPATH_TYPE_NAME = 'Dualpath'
|
||||
|
||||
|
||||
def _naturalkey(assetnumber):
|
||||
# split into digit/non-digit chunks so 2007 sorts before 2008 and before 10a
|
||||
parts = re.split(r'(\d+)', assetnumber or '')
|
||||
return [int(p) if p.isdigit() else p.lower() for p in parts]
|
||||
|
||||
|
||||
def resolve_dualpath_pairs():
|
||||
"""Resolve active Dualpath pairs into a DualpathCollapse.
|
||||
|
||||
Direction-blind and dedup-safe: rows stored in either direction (or both)
|
||||
for the same two assets collapse to one pair. Ignores the site toggle;
|
||||
callers gate on dualpath_single_machine_enabled() where the collapse should
|
||||
only apply when the setting is on (the detail-page banner shows always).
|
||||
"""
|
||||
reltype = RelationshipType.query.filter_by(
|
||||
relationshiptype=DUALPATH_TYPE_NAME).first()
|
||||
if not reltype:
|
||||
return DualpathCollapse(set(), {})
|
||||
|
||||
rows = AssetRelationship.query.filter(
|
||||
AssetRelationship.relationshiptypeid == reltype.relationshiptypeid,
|
||||
AssetRelationship.isactive == True,
|
||||
).all()
|
||||
if not rows:
|
||||
return DualpathCollapse(set(), {})
|
||||
|
||||
# batch-load the involved assets (assetnumber + active flag) in one query
|
||||
involved = set()
|
||||
for row in rows:
|
||||
involved.add(row.sourceassetid)
|
||||
involved.add(row.targetassetid)
|
||||
assetbyid = {
|
||||
a.assetid: a
|
||||
for a in Asset.query.filter(Asset.assetid.in_(involved)).all()
|
||||
}
|
||||
|
||||
secondaryassetids = set()
|
||||
partnerbyasset = {}
|
||||
seenpairs = set()
|
||||
for row in rows:
|
||||
aid, bid = row.sourceassetid, row.targetassetid
|
||||
if aid == bid:
|
||||
continue # defensive: no self-pairs
|
||||
pairkey = frozenset((aid, bid))
|
||||
if pairkey in seenpairs:
|
||||
continue # mirrored row already handled
|
||||
seenpairs.add(pairkey)
|
||||
|
||||
aone = assetbyid.get(aid)
|
||||
atwo = assetbyid.get(bid)
|
||||
# collapse only affects visible/counted (active) assets
|
||||
if not aone or not atwo or not aone.isactive or not atwo.isactive:
|
||||
continue
|
||||
|
||||
# PRIMARY = lower natural-sort assetnumber; SECONDARY is the other bay
|
||||
if _naturalkey(aone.assetnumber) <= _naturalkey(atwo.assetnumber):
|
||||
primary, secondary = aone, atwo
|
||||
else:
|
||||
primary, secondary = atwo, aone
|
||||
|
||||
secondaryassetids.add(secondary.assetid)
|
||||
partnerbyasset[primary.assetid] = {
|
||||
'assetid': secondary.assetid,
|
||||
'assetnumber': secondary.assetnumber,
|
||||
}
|
||||
partnerbyasset[secondary.assetid] = {
|
||||
'assetid': primary.assetid,
|
||||
'assetnumber': primary.assetnumber,
|
||||
}
|
||||
|
||||
return DualpathCollapse(secondaryassetids, partnerbyasset)
|
||||
|
||||
|
||||
def dualpath_single_machine_enabled():
|
||||
"""True when the site treats Dualpath pairs as one machine (default true).
|
||||
|
||||
Reads the cached settings; the row is absent on an un-seeded site, in which
|
||||
case the default (true - 'most places' consider a dual-bay pair one machine)
|
||||
applies.
|
||||
"""
|
||||
# imported here to avoid a settings<->api import cycle at module load
|
||||
from shopdb.core.api.settings import get_cached_settings
|
||||
settings = get_cached_settings()
|
||||
return bool(settings.get('dualpath_single_machine', True))
|
||||
Reference in New Issue
Block a user