feat(unit-swap): show benched candidates and clean stale modem pairings
`available-units` and `available-modems` now accept `include_benched=true` to also return units/modems with `deployed=False`. Default is False so the existing location-detail swap modal is unchanged. Each row carries a `deployed` boolean for badge rendering. The Unit Swap wizard fetches with the flag enabled — exactly the candidates a field tech pulls off the shelf. The /swap endpoint now flips the incoming unit (and modem) back to `deployed=True` when they came in benched, keeping the legacy roster flag consistent with the active-assignment signal. Adds the symmetric half of the orphan-pairing fix: when a newly-paired modem still claims a different seismograph (whose `deployed_with_modem_id` was never cleared in a past swap), break that stale back-reference before re-pairing. `locations-with-assignments` includes `modem.deployed` so the wizard can badge the current modem in the location card, the "Keep current modem" choice, the picker rows, and the review screen. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -318,6 +318,7 @@ async def get_locations_with_assignments(
|
|||||||
"hardware_model": modem.hardware_model,
|
"hardware_model": modem.hardware_model,
|
||||||
"ip_address": modem.ip_address,
|
"ip_address": modem.ip_address,
|
||||||
"phone_number": modem.phone_number,
|
"phone_number": modem.phone_number,
|
||||||
|
"deployed": bool(modem.deployed),
|
||||||
}
|
}
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
@@ -1306,12 +1307,28 @@ async def swap_unit_on_location(
|
|||||||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||||
if not modem:
|
if not modem:
|
||||||
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
|
||||||
|
# Symmetric cleanup: if this modem still claims a previous partner
|
||||||
|
# (a different seismograph whose deployed_with_modem_id never got
|
||||||
|
# cleared in a past swap), break that stale link before re-pairing.
|
||||||
|
if modem.deployed_with_unit_id and modem.deployed_with_unit_id != unit_id:
|
||||||
|
prev_partner = db.query(RosterUnit).filter_by(id=modem.deployed_with_unit_id).first()
|
||||||
|
if prev_partner and prev_partner.deployed_with_modem_id == modem_id:
|
||||||
|
prev_partner.deployed_with_modem_id = None
|
||||||
unit.deployed_with_modem_id = modem_id
|
unit.deployed_with_modem_id = modem_id
|
||||||
modem.deployed_with_unit_id = unit_id
|
modem.deployed_with_unit_id = unit_id
|
||||||
|
# If the modem was on the bench, swapping it into the field puts it
|
||||||
|
# back in rotation.
|
||||||
|
if not modem.deployed:
|
||||||
|
modem.deployed = True
|
||||||
else:
|
else:
|
||||||
# Clear modem pairing if not provided
|
# Clear modem pairing if not provided
|
||||||
unit.deployed_with_modem_id = None
|
unit.deployed_with_modem_id = None
|
||||||
|
|
||||||
|
# If the incoming unit was benched, putting it in the field flips it
|
||||||
|
# back to deployed (so polling / dashboards see it as in rotation).
|
||||||
|
if not unit.deployed:
|
||||||
|
unit.deployed = True
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -1329,23 +1346,32 @@ async def swap_unit_on_location(
|
|||||||
async def get_available_modems(
|
async def get_available_modems(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
include_benched: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all deployed, non-retired modems for the modem assignment dropdown.
|
Get all non-retired modems for the modem assignment dropdown.
|
||||||
|
|
||||||
|
By default only deployed (in-rotation) modems are returned, preserving
|
||||||
|
the existing behavior for callers like the location-detail swap modal.
|
||||||
|
Pass ``include_benched=true`` to also include benched modems
|
||||||
|
(``RosterUnit.deployed == False``) — useful when picking a modem to
|
||||||
|
pull off the bench for a field swap. Each row's ``deployed`` flag is
|
||||||
|
returned so the UI can badge benched candidates.
|
||||||
"""
|
"""
|
||||||
modems = db.query(RosterUnit).filter(
|
filters = [
|
||||||
and_(
|
|
||||||
RosterUnit.device_type == "modem",
|
RosterUnit.device_type == "modem",
|
||||||
RosterUnit.deployed == True,
|
|
||||||
RosterUnit.retired == False,
|
RosterUnit.retired == False,
|
||||||
)
|
]
|
||||||
).order_by(RosterUnit.id).all()
|
if not include_benched:
|
||||||
|
filters.append(RosterUnit.deployed == True)
|
||||||
|
modems = db.query(RosterUnit).filter(and_(*filters)).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": m.id,
|
"id": m.id,
|
||||||
"hardware_model": m.hardware_model,
|
"hardware_model": m.hardware_model,
|
||||||
"ip_address": m.ip_address,
|
"ip_address": m.ip_address,
|
||||||
|
"deployed": bool(m.deployed),
|
||||||
}
|
}
|
||||||
for m in modems
|
for m in modems
|
||||||
]
|
]
|
||||||
@@ -1356,22 +1382,31 @@ async def get_available_units(
|
|||||||
project_id: str,
|
project_id: str,
|
||||||
location_type: str = Query(...),
|
location_type: str = Query(...),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
include_benched: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get list of available units for assignment to a location.
|
Get list of available units for assignment to a location.
|
||||||
Filters by device type matching the location type.
|
Filters by device type matching the location type.
|
||||||
|
|
||||||
|
By default only deployed (in-rotation) units are returned, preserving
|
||||||
|
the existing location-detail swap-modal behavior. Pass
|
||||||
|
``include_benched=true`` to also include benched units
|
||||||
|
(``RosterUnit.deployed == False``) — exactly the candidates you'd
|
||||||
|
pull off the bench for a field swap. Each row carries a ``deployed``
|
||||||
|
flag so the UI can badge benched picks.
|
||||||
"""
|
"""
|
||||||
# Determine required device type
|
# Determine required device type
|
||||||
required_device_type = "slm" if location_type == "sound" else "seismograph"
|
required_device_type = "slm" if location_type == "sound" else "seismograph"
|
||||||
|
|
||||||
# Get all units of the required type that are deployed and not retired
|
# Get all units of the required type that aren't retired (and optionally
|
||||||
all_units = db.query(RosterUnit).filter(
|
# exclude benched units).
|
||||||
and_(
|
filters = [
|
||||||
RosterUnit.device_type == required_device_type,
|
RosterUnit.device_type == required_device_type,
|
||||||
RosterUnit.deployed == True,
|
|
||||||
RosterUnit.retired == False,
|
RosterUnit.retired == False,
|
||||||
)
|
]
|
||||||
).all()
|
if not include_benched:
|
||||||
|
filters.append(RosterUnit.deployed == True)
|
||||||
|
all_units = db.query(RosterUnit).filter(and_(*filters)).all()
|
||||||
|
|
||||||
# Filter out units that already have active assignments (active = assigned_until IS NULL)
|
# Filter out units that already have active assignments (active = assigned_until IS NULL)
|
||||||
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
||||||
@@ -1385,6 +1420,7 @@ async def get_available_units(
|
|||||||
"device_type": unit.device_type,
|
"device_type": unit.device_type,
|
||||||
"location": unit.address or unit.location,
|
"location": unit.address or unit.location,
|
||||||
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||||||
|
"deployed": bool(unit.deployed),
|
||||||
}
|
}
|
||||||
for unit in all_units
|
for unit in all_units
|
||||||
if unit.id not in assigned_unit_ids
|
if unit.id not in assigned_unit_ids
|
||||||
|
|||||||
@@ -232,6 +232,12 @@ function _esc(s) {
|
|||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _badge(deployed) {
|
||||||
|
return deployed
|
||||||
|
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">deployed</span>'
|
||||||
|
: '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">benched</span>';
|
||||||
|
}
|
||||||
|
|
||||||
function swapGoToStep(n) {
|
function swapGoToStep(n) {
|
||||||
_swap.step = n;
|
_swap.step = n;
|
||||||
for (let i = 1; i <= 5; i++) {
|
for (let i = 1; i <= 5; i++) {
|
||||||
@@ -339,7 +345,10 @@ function _swapRenderLocations(locations) {
|
|||||||
? `<div class="text-xs text-gray-600 dark:text-gray-300 font-mono">${_esc(unit.id)}<span class="text-gray-400">${unit.unit_type ? ' · ' + _esc(unit.unit_type) : ''}</span></div>`
|
? `<div class="text-xs text-gray-600 dark:text-gray-300 font-mono">${_esc(unit.id)}<span class="text-gray-400">${unit.unit_type ? ' · ' + _esc(unit.unit_type) : ''}</span></div>`
|
||||||
: `<div class="text-xs italic text-gray-400">Empty — first assign</div>`;
|
: `<div class="text-xs italic text-gray-400">Empty — first assign</div>`;
|
||||||
const modemLine = modem
|
const modemLine = modem
|
||||||
? `<div class="text-[11px] text-gray-500 dark:text-gray-400 font-mono">+ modem ${_esc(modem.id)}</div>`
|
? `<div class="text-[11px] text-gray-500 dark:text-gray-400 font-mono mt-0.5 flex items-center gap-2">
|
||||||
|
<span>+ modem ${_esc(modem.id)}</span>
|
||||||
|
${_badge(modem.deployed)}
|
||||||
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
// Pass index for cleaner attribute escaping
|
// Pass index for cleaner attribute escaping
|
||||||
return `<button data-locidx="${locations.indexOf(loc)}" onclick="_swapPickLocationByIdx(this.dataset.locidx)"
|
return `<button data-locidx="${locations.indexOf(loc)}" onclick="_swapPickLocationByIdx(this.dataset.locidx)"
|
||||||
@@ -370,7 +379,7 @@ async function _swapLoadUnits() {
|
|||||||
const list = document.getElementById('swap-unit-list');
|
const list = document.getElementById('swap-unit-list');
|
||||||
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-units?location_type=vibration`);
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-units?location_type=vibration&include_benched=true`);
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
_swap.all_units = await r.json();
|
_swap.all_units = await r.json();
|
||||||
_swapRenderUnits();
|
_swapRenderUnits();
|
||||||
@@ -396,12 +405,16 @@ function _swapRenderUnits() {
|
|||||||
list.innerHTML = items.map(u => {
|
list.innerHTML = items.map(u => {
|
||||||
const model = u.model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(u.model)}</span>` : '';
|
const model = u.model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(u.model)}</span>` : '';
|
||||||
const loc = u.location ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.location)}</div>` : '';
|
const loc = u.location ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.location)}</div>` : '';
|
||||||
|
const badge = _badge(u.deployed);
|
||||||
return `<button onclick='swapPickUnit(${JSON.stringify(u.id)})'
|
return `<button onclick='swapPickUnit(${JSON.stringify(u.id)})'
|
||||||
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
|
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${badge}
|
||||||
${model}
|
${model}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
${loc}
|
${loc}
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -428,11 +441,14 @@ function _swapInitModemStep() {
|
|||||||
const question = document.getElementById('swap-modem-question');
|
const question = document.getElementById('swap-modem-question');
|
||||||
|
|
||||||
if (current) {
|
if (current) {
|
||||||
question.textContent = `Modem currently at this location: ${current.id}`;
|
question.textContent = 'Modem at this location';
|
||||||
choices.innerHTML = `
|
choices.innerHTML = `
|
||||||
<button data-action="keep" onclick="swapPickModemAction('keep')"
|
<button data-action="keep" onclick="swapPickModemAction('keep')"
|
||||||
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div>
|
<div class="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div>
|
||||||
|
${_badge(current.deployed)}
|
||||||
|
</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">Re-pair the existing modem to the incoming unit.</div>
|
<div class="text-xs text-gray-500 dark:text-gray-400">Re-pair the existing modem to the incoming unit.</div>
|
||||||
</button>
|
</button>
|
||||||
<button data-action="swap" onclick="swapPickModemAction('swap')"
|
<button data-action="swap" onclick="swapPickModemAction('swap')"
|
||||||
@@ -490,7 +506,7 @@ async function _swapLoadModems() {
|
|||||||
const list = document.getElementById('swap-modem-list');
|
const list = document.getElementById('swap-modem-list');
|
||||||
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-modems`);
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-modems?include_benched=true`);
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
// Filter out the modem that's currently at this location (it's the "keep" option, not "swap").
|
// Filter out the modem that's currently at this location (it's the "keep" option, not "swap").
|
||||||
let modems = await r.json();
|
let modems = await r.json();
|
||||||
@@ -520,13 +536,17 @@ function _swapRenderModems() {
|
|||||||
list.innerHTML = items.map(m => {
|
list.innerHTML = items.map(m => {
|
||||||
const hw = m.hardware_model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(m.hardware_model)}</span>` : '';
|
const hw = m.hardware_model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(m.hardware_model)}</span>` : '';
|
||||||
const ip = m.ip_address ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">${_esc(m.ip_address)}</div>` : '';
|
const ip = m.ip_address ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">${_esc(m.ip_address)}</div>` : '';
|
||||||
|
const badge = _badge(m.deployed);
|
||||||
return `<button onclick='swapPickModem(${JSON.stringify(m.id)})'
|
return `<button onclick='swapPickModem(${JSON.stringify(m.id)})'
|
||||||
class="swap-modem-pick w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors"
|
class="swap-modem-pick w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors"
|
||||||
data-modem-id="${_esc(m.id)}">
|
data-modem-id="${_esc(m.id)}">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(m.id)}</span>
|
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(m.id)}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${badge}
|
||||||
${hw}
|
${hw}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
${ip}
|
${ip}
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -553,16 +573,21 @@ function swapAfterModem() {
|
|||||||
document.getElementById('swap-review-old-unit').textContent = _swap.location.unit ? _swap.location.unit.id : '(empty)';
|
document.getElementById('swap-review-old-unit').textContent = _swap.location.unit ? _swap.location.unit.id : '(empty)';
|
||||||
document.getElementById('swap-review-new-unit').textContent = _swap.new_unit.id;
|
document.getElementById('swap-review-new-unit').textContent = _swap.new_unit.id;
|
||||||
|
|
||||||
const oldModem = _swap.location.modem ? _swap.location.modem.id : '(none)';
|
const oldModemEl = document.getElementById('swap-review-old-modem');
|
||||||
document.getElementById('swap-review-old-modem').textContent = oldModem;
|
if (_swap.location.modem) {
|
||||||
|
oldModemEl.innerHTML = `${_esc(_swap.location.modem.id)} ${_badge(_swap.location.modem.deployed)}`;
|
||||||
let newModem = '(none)';
|
} else {
|
||||||
if (_swap.modem_action === 'keep' && _swap.location.modem) {
|
oldModemEl.textContent = '(none)';
|
||||||
newModem = _swap.location.modem.id + ' (kept)';
|
}
|
||||||
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
|
|
||||||
newModem = _swap.new_modem.id;
|
const newModemEl = document.getElementById('swap-review-new-modem');
|
||||||
|
if (_swap.modem_action === 'keep' && _swap.location.modem) {
|
||||||
|
newModemEl.innerHTML = `${_esc(_swap.location.modem.id)} <span class="text-xs text-gray-500">(kept)</span> ${_badge(_swap.location.modem.deployed)}`;
|
||||||
|
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
|
||||||
|
newModemEl.innerHTML = `${_esc(_swap.new_modem.id)} ${_badge(_swap.new_modem.deployed)}`;
|
||||||
|
} else {
|
||||||
|
newModemEl.textContent = '(none)';
|
||||||
}
|
}
|
||||||
document.getElementById('swap-review-new-modem').textContent = newModem;
|
|
||||||
|
|
||||||
swapGoToStep(5);
|
swapGoToStep(5);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user