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:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -12,6 +12,17 @@ ADR-007 and ADR-002.
|
||||
|
||||
### Added
|
||||
|
||||
- Dualpath "single machine" site toggle (`dualpath_single_machine`, default
|
||||
on). A Dualpath relationship pair is one physical dual-bay machine (single
|
||||
controller, bay-selector switch); when on, the machines list, dashboard and
|
||||
machines-by-type counts, and the floor map collapse each pair to one entry
|
||||
(the lower natural-sort assetnumber is PRIMARY; the SECONDARY bay is hidden)
|
||||
and show a combined `2007 / 2008` label. The data model is unchanged (both
|
||||
bay records always exist); detail pages stay per-bay and always show a
|
||||
sibling-bay banner regardless of the toggle. Contract surface (plugin
|
||||
contract bumped 0.8.0 -> 0.9.0, additive): new `shopdb.api` helpers
|
||||
`resolve_dualpath_pairs` and `dualpath_single_machine_enabled`, consumed by
|
||||
the machines plugin to collapse pairs contract-purely.
|
||||
- Relationship propagation, wired and data-driven: relationship types
|
||||
declare propagation-through pairs (relationshiptypepropagations M:N,
|
||||
replacing the never-consumed single column); creating a controls link on
|
||||
|
||||
@@ -135,6 +135,7 @@ read back through the API.
|
||||
| `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. |
|
||||
| `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. |
|
||||
| `printer_hostname_template` | `Printer-{ip}.printer.geaerospace.net` | Printer hostname template. `{ip}` is the dash-separated IP address. |
|
||||
| `dualpath_single_machine` | `true` | Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in the machines list, dashboard/report counts, and the floor map (the secondary bay is hidden). The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. `false` lists and counts both bays separately. |
|
||||
|
||||
### branding
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
||||
The framework declares its contract version in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.8.0'
|
||||
__contract_version__ = '0.9.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
@@ -429,7 +429,8 @@ What `shopdb.api` exposes:
|
||||
`ErrorCodes`
|
||||
- Pagination: `get_pagination_params`, `paginate_query`
|
||||
- Authorization: `require_permission`, `require_role`
|
||||
- Helpers: `audit_log`, `resolve_asset_position`
|
||||
- Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
|
||||
`dualpath_single_machine_enabled`
|
||||
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
||||
`parse_import_datetime`
|
||||
- Legacy employee directory: `employee_connection`
|
||||
@@ -482,6 +483,31 @@ position = resolve_asset_position(asset)
|
||||
|
||||
See [ADR-001](../docs/adr/ADR-001-asset-as-platform-contract.md) for the position resolution algorithm.
|
||||
|
||||
### Dualpath single-machine collapse
|
||||
|
||||
A Dualpath relationship pair is one physical dual-bay machine recorded as two
|
||||
asset rows. When the site setting `dualpath_single_machine` is on (default), the
|
||||
machines list, dashboard/report counts, and the floor map show the pair as a
|
||||
single machine (the SECONDARY bay is hidden); the data model always keeps both
|
||||
rows and detail pages stay per-bay.
|
||||
|
||||
```python
|
||||
from shopdb.api import resolve_dualpath_pairs, dualpath_single_machine_enabled
|
||||
|
||||
collapse = resolve_dualpath_pairs()
|
||||
# collapse.secondaryassetids: set of the non-primary bay asset ids to hide
|
||||
# collapse.partnerbyasset: {assetid -> {'assetid', 'assetnumber'}} for every
|
||||
# pair member (primary and secondary), for banners
|
||||
|
||||
if dualpath_single_machine_enabled():
|
||||
# exclude the hidden bays and annotate the visible (primary) bay
|
||||
...
|
||||
```
|
||||
|
||||
PRIMARY is the pair member with the lower natural-sort assetnumber.
|
||||
`resolve_dualpath_pairs` ignores the toggle (so a detail-page sibling banner can
|
||||
show always); gate the collapse itself on `dualpath_single_machine_enabled()`.
|
||||
|
||||
### Import mode (legacy timestamp passthrough)
|
||||
|
||||
Bulk imports from the classic ASP shopdb need to preserve each row's original
|
||||
|
||||
@@ -384,6 +384,10 @@ function renderMarkers() {
|
||||
typeName = item.assettype || ''
|
||||
}
|
||||
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
|
||||
// Collapsed dual-bay pair: show the combined '2007 / 2008' label.
|
||||
if (item.dualpathpartner) {
|
||||
displayName = `${item.assetnumber} / ${item.dualpathpartner}`
|
||||
}
|
||||
detailRoute = getAssetDetailRoute(item)
|
||||
} else {
|
||||
// Legacy machine mode
|
||||
|
||||
@@ -16,6 +16,16 @@
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="machine">
|
||||
<!-- Dual-bay sibling banner: a Dualpath pair is one physical machine -->
|
||||
<div v-if="machine.dualpathpartner" class="dualpath-banner">
|
||||
Dual-bay machine - sibling bay:
|
||||
<router-link
|
||||
v-if="machine.dualpathpartner.machineid"
|
||||
:to="`/machines/${machine.dualpathpartner.machineid}`"
|
||||
>{{ machine.dualpathpartner.assetnumber }}</router-link>
|
||||
<span v-else>{{ machine.dualpathpartner.assetnumber }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
@@ -264,6 +274,22 @@ function formatDate(dateStr) {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.dualpath-banner {
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--primary);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dualpath-banner a {
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.625rem;
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in machines" :key="item.assetid">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<td>
|
||||
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
|
||||
</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.machine?.machinetypename || '-' }}</td>
|
||||
|
||||
@@ -44,7 +44,8 @@ const LABELS = {
|
||||
facility_name: 'Facility Name',
|
||||
pc_access_domain: 'PC Access Domain',
|
||||
employeeid_pattern: 'Employee ID Pattern',
|
||||
printer_hostname_template: 'Printer Hostname Template'
|
||||
printer_hostname_template: 'Printer Hostname Template',
|
||||
dualpath_single_machine: 'Dualpath as Single Machine'
|
||||
}
|
||||
function prettyLabel(key) {
|
||||
return LABELS[key] || key
|
||||
@@ -53,7 +54,8 @@ function prettyLabel(key) {
|
||||
// Inline help for site fields that need more than the stored description.
|
||||
const HELP = {
|
||||
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
|
||||
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net'
|
||||
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net',
|
||||
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.'
|
||||
}
|
||||
function fieldHelp(key) {
|
||||
return HELP[key] || ''
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled
|
||||
|
||||
from ..models import Machine, MachineType
|
||||
|
||||
@@ -213,18 +213,61 @@ def list_machines():
|
||||
|
||||
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
|
||||
|
||||
# Dualpath single-machine collapse: hide the secondary bay so a dual-bay
|
||||
# pair lists (and paginates) as one machine. Excluding before pagination
|
||||
# keeps totals honest. Gated on the site setting (default on).
|
||||
collapse = None
|
||||
if dualpath_single_machine_enabled():
|
||||
collapse = resolve_dualpath_pairs()
|
||||
if collapse.secondaryassetids:
|
||||
query = query.filter(Asset.assetid.notin_(collapse.secondaryassetids))
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
|
||||
# Resolve partner machineids for this page's primaries in one query.
|
||||
machineidbyasset = {}
|
||||
if collapse:
|
||||
partnerassetids = [
|
||||
collapse.partnerbyasset[m.assetid]['assetid']
|
||||
for m in items if m.assetid in collapse.partnerbyasset
|
||||
]
|
||||
if partnerassetids:
|
||||
for partner in Machine.query.filter(
|
||||
Machine.assetid.in_(partnerassetids)).all():
|
||||
machineidbyasset[partner.assetid] = partner.machineid
|
||||
|
||||
# Build response with both asset and machine data
|
||||
data = []
|
||||
for mach in items:
|
||||
item = mach.asset.to_dict() if mach.asset else {}
|
||||
item['machine'] = mach.to_dict()
|
||||
# annotate the visible bay with its hidden partner (for '2007 / 2008')
|
||||
partner = collapse.partnerbyasset.get(mach.assetid) if collapse else None
|
||||
item['dualpathpartner'] = {
|
||||
'assetid': partner['assetid'],
|
||||
'machineid': machineidbyasset.get(partner['assetid']),
|
||||
'assetnumber': partner['assetnumber'],
|
||||
} if partner else None
|
||||
data.append(item)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
def _dualpath_partner_for(assetid):
|
||||
"""Resolve this machine's Dualpath sibling, or None. Always evaluated
|
||||
(independent of the collapse toggle) so the detail-page banner shows even
|
||||
when a site lists both bays. Returns {assetid, machineid, assetnumber}."""
|
||||
partner = resolve_dualpath_pairs().partnerbyasset.get(assetid)
|
||||
if not partner:
|
||||
return None
|
||||
partnermach = Machine.query.filter_by(assetid=partner['assetid']).first()
|
||||
return {
|
||||
'assetid': partner['assetid'],
|
||||
'machineid': partnermach.machineid if partnermach else None,
|
||||
'assetnumber': partner['assetnumber'],
|
||||
}
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_machine(machine_id: int):
|
||||
@@ -240,6 +283,7 @@ def get_machine(machine_id: int):
|
||||
|
||||
result = mach.asset.to_dict() if mach.asset else {}
|
||||
result['machine'] = mach.to_dict()
|
||||
result['dualpathpartner'] = _dualpath_partner_for(mach.assetid)
|
||||
|
||||
return success_response(result)
|
||||
|
||||
@@ -259,6 +303,7 @@ def get_machine_by_asset(asset_id: int):
|
||||
|
||||
result = mach.asset.to_dict() if mach.asset else {}
|
||||
result['machine'] = mach.to_dict()
|
||||
result['dualpathpartner'] = _dualpath_partner_for(mach.assetid)
|
||||
|
||||
return success_response(result)
|
||||
|
||||
@@ -462,20 +507,30 @@ def delete_machine(machine_id: int):
|
||||
@jwt_required(optional=True)
|
||||
def dashboard_summary():
|
||||
"""Get machine dashboard summary data."""
|
||||
# Dualpath single-machine collapse: hide the secondary bay from counts so a
|
||||
# dual-bay pair counts once. Gated on the site setting (default on).
|
||||
secondaryassetids = set()
|
||||
if dualpath_single_machine_enabled():
|
||||
secondaryassetids = resolve_dualpath_pairs().secondaryassetids
|
||||
|
||||
# Total active machine count
|
||||
total = db.session.query(Machine).join(Asset).filter(
|
||||
total_query = db.session.query(Machine).join(Asset).filter(
|
||||
Asset.isactive == True
|
||||
).count()
|
||||
)
|
||||
if secondaryassetids:
|
||||
total_query = total_query.filter(Asset.assetid.notin_(secondaryassetids))
|
||||
total = total_query.count()
|
||||
|
||||
# Count by machine type
|
||||
by_type = db.session.query(
|
||||
by_type_query = db.session.query(
|
||||
MachineType.machinetype,
|
||||
db.func.count(Machine.machineid)
|
||||
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
|
||||
).join(Asset, Asset.assetid == Machine.assetid
|
||||
).filter(Asset.isactive == True
|
||||
).group_by(MachineType.machinetype
|
||||
).all()
|
||||
).filter(Asset.isactive == True)
|
||||
if secondaryassetids:
|
||||
by_type_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids))
|
||||
by_type = by_type_query.group_by(MachineType.machinetype).all()
|
||||
|
||||
# Count by status
|
||||
from shopdb.api import AssetStatus
|
||||
|
||||
@@ -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))
|
||||
@@ -247,3 +247,49 @@ def test_cli_backfill_propagates_and_is_idempotent(client, db, auth_headers, run
|
||||
sourceassetid=pc.assetid, targetassetid=bayb.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).count()
|
||||
assert count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI fix-controls-direction flips reversed legacy rows to PC -> machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cli_fix_controls_direction(client, db, auth_headers, runner):
|
||||
atype, controls, dualpath, partof = _setup_types()
|
||||
ctype = AssetType(assettype='computer')
|
||||
db.session.add(ctype)
|
||||
db.session.flush()
|
||||
|
||||
pc = _make_asset('PC-7', ctype.assettypeid)
|
||||
pc2 = _make_asset('PC-8', ctype.assettypeid)
|
||||
bay = _make_asset('BAY-7', atype.assettypeid)
|
||||
bay2 = _make_asset('BAY-8', atype.assettypeid)
|
||||
|
||||
# reversed legacy row: machine -> PC
|
||||
db.session.add(AssetRelationship(sourceassetid=bay.assetid, targetassetid=pc.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
# reversed row whose flip already exists -> reversed one gets deactivated
|
||||
db.session.add(AssetRelationship(sourceassetid=bay2.assetid, targetassetid=pc2.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
db.session.add(AssetRelationship(sourceassetid=pc2.assetid, targetassetid=bay2.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
result = runner.invoke(args=['relationships', 'fix-controls-direction'])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert 'Flipped 1' in result.output
|
||||
assert '1 reversed duplicate(s) deactivated' in result.output
|
||||
|
||||
# first row now reads PC -> machine
|
||||
assert _rel_exists(pc.assetid, bay.assetid, controls.relationshiptypeid)
|
||||
assert not _rel_exists(bay.assetid, pc.assetid, controls.relationshiptypeid)
|
||||
# duplicate pair: canonical row stays active, reversed one deactivated
|
||||
rev = AssetRelationship.query.filter_by(
|
||||
sourceassetid=bay2.assetid, targetassetid=pc2.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).first()
|
||||
assert rev is not None and rev.isactive is False
|
||||
|
||||
# idempotent: second run finds nothing
|
||||
result2 = runner.invoke(args=['relationships', 'fix-controls-direction'])
|
||||
assert result2.exit_code == 0, result2.output
|
||||
assert 'Flipped 0' in result2.output
|
||||
assert '0 reversed duplicate(s) deactivated' in result2.output
|
||||
|
||||
163
tests/test_plugins/test_dualpath_single.py
Normal file
163
tests/test_plugins/test_dualpath_single.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Dualpath single-machine collapse (site setting dualpath_single_machine).
|
||||
|
||||
A Dualpath pair is one physical dual-bay machine recorded as two asset rows.
|
||||
With the setting on (default), the machines list, dashboard machine count, and
|
||||
the floor map collapse the pair to the PRIMARY bay (lower natural-sort
|
||||
assetnumber) and annotate it with its hidden partner. With the setting off,
|
||||
both bays list and count separately. The data model always keeps both rows.
|
||||
|
||||
Settings are cached, so each test invalidates the cache after seeding.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
|
||||
from plugins.machines.models import Machine
|
||||
|
||||
|
||||
def _seed_dualpath_pair():
|
||||
"""Create a 'machine' AssetType, two machine assets (2007, 2008) placed on
|
||||
the map, and an active Dualpath relationship between them. 2007 is PRIMARY
|
||||
(lower natural-sort). Returns (primary_machine, secondary_machine)."""
|
||||
machinetype = AssetType(assettype='machine')
|
||||
_db.session.add(machinetype)
|
||||
_db.session.flush()
|
||||
|
||||
dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False)
|
||||
_db.session.add(dualpath)
|
||||
_db.session.flush()
|
||||
|
||||
primary_asset = Asset(assetnumber='2007', assettypeid=machinetype.assettypeid,
|
||||
mapx=100, mapy=100)
|
||||
secondary_asset = Asset(assetnumber='2008', assettypeid=machinetype.assettypeid,
|
||||
mapx=110, mapy=110)
|
||||
_db.session.add_all([primary_asset, secondary_asset])
|
||||
_db.session.flush()
|
||||
|
||||
primary_machine = Machine(assetid=primary_asset.assetid)
|
||||
secondary_machine = Machine(assetid=secondary_asset.assetid)
|
||||
_db.session.add_all([primary_machine, secondary_machine])
|
||||
_db.session.flush()
|
||||
|
||||
_db.session.add(AssetRelationship(
|
||||
sourceassetid=primary_asset.assetid,
|
||||
targetassetid=secondary_asset.assetid,
|
||||
relationshiptypeid=dualpath.relationshiptypeid,
|
||||
))
|
||||
_db.session.commit()
|
||||
return primary_machine, secondary_machine
|
||||
|
||||
|
||||
def _set_toggle(value):
|
||||
Setting.set('dualpath_single_machine', value, valuetype='boolean',
|
||||
category='site')
|
||||
invalidate_settings_cache()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setting ON (default): pair collapses to the primary bay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_collapses_secondary_and_annotates_primary(client, db):
|
||||
invalidate_settings_cache() # default on (no row)
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/machines')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
payload = resp.get_json()
|
||||
items = payload['data']
|
||||
numbers = [i['assetnumber'] for i in items]
|
||||
|
||||
# secondary bay (2008) is hidden; total reflects the collapse
|
||||
assert numbers == ['2007']
|
||||
assert payload['meta']['pagination']['total'] == 1
|
||||
|
||||
# the visible primary carries its partner
|
||||
partner = items[0]['dualpathpartner']
|
||||
assert partner is not None
|
||||
assert partner['assetnumber'] == '2008'
|
||||
assert partner['assetid'] is not None
|
||||
assert partner['machineid'] is not None
|
||||
|
||||
|
||||
def test_dashboard_machine_count_collapses(client, db):
|
||||
invalidate_settings_cache()
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/dashboard')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
counts = resp.get_json()['data']
|
||||
# two machine rows, one physical machine
|
||||
assert counts['totalmachines'] == 1
|
||||
assert counts['counts']['machines'] == 1
|
||||
|
||||
|
||||
def test_map_excludes_secondary_marker(client, db):
|
||||
invalidate_settings_cache()
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/assets/map')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assets = resp.get_json()['data']['assets']
|
||||
numbers = [a['assetnumber'] for a in assets]
|
||||
|
||||
assert '2007' in numbers
|
||||
assert '2008' not in numbers
|
||||
primary = next(a for a in assets if a['assetnumber'] == '2007')
|
||||
assert primary['dualpathpartner'] == '2008'
|
||||
|
||||
|
||||
def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
||||
invalidate_settings_cache()
|
||||
primary_machine, secondary_machine = _seed_dualpath_pair()
|
||||
|
||||
# banner data is present on BOTH bays even with the toggle off
|
||||
_set_toggle(False)
|
||||
for machine_id, expected in (
|
||||
(primary_machine.machineid, '2008'),
|
||||
(secondary_machine.machineid, '2007'),
|
||||
):
|
||||
resp = client.get(f'/api/machines/{machine_id}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
partner = resp.get_json()['data']['dualpathpartner']
|
||||
assert partner is not None
|
||||
assert partner['assetnumber'] == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setting OFF: both bays list and count separately
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_shows_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/machines')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
payload = resp.get_json()
|
||||
numbers = sorted(i['assetnumber'] for i in payload['data'])
|
||||
|
||||
assert numbers == ['2007', '2008']
|
||||
assert payload['meta']['pagination']['total'] == 2
|
||||
|
||||
|
||||
def test_dashboard_counts_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/dashboard')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['totalmachines'] == 2
|
||||
|
||||
|
||||
def test_map_shows_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/assets/map')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
|
||||
assert '2007' in numbers
|
||||
assert '2008' in numbers
|
||||
Reference in New Issue
Block a user