Add bulk install-in-device from dashboard and unretire action
- Dashboard bulk toolbar: select batteries, pick a device, click 'Install in device'; confirms before moving already-installed batteries; enforces slot capacity and warns on brand mix - Battery detail: 'Unretire Battery' button replaces 'Retire Battery' when battery is retired, restoring it to available status - Tests: 3 new bulk-install-device tests (capacity block, move, success); 42 total passing
This commit is contained in:
@@ -225,6 +225,23 @@ def create_app(config_object="config"):
|
||||
flash(f"{battery.label} has been retired.", "success")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — unretire
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@app.route("/battery/<int:battery_id>/unretire", methods=["POST"])
|
||||
def battery_unretire(battery_id):
|
||||
battery = db.get(Battery, battery_id)
|
||||
if battery is None:
|
||||
abort(404)
|
||||
if not battery.is_retired():
|
||||
flash(f"{battery.label} is not retired.", "error")
|
||||
return redirect(url_for("battery_detail", battery_id=battery_id))
|
||||
battery.status = "available"
|
||||
db.commit()
|
||||
flash(f"{battery.label} is now available again.", "success")
|
||||
return redirect(url_for("battery_detail", battery_id=battery_id))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — delete
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -285,6 +302,55 @@ def create_app(config_object="config"):
|
||||
b.brand = new_brand
|
||||
db.commit()
|
||||
flash(f"Updated brand to '{new_brand}' for {n} batter{'y' if n == 1 else 'ies'}.", "success")
|
||||
elif action == "install_device":
|
||||
device_id = request.form.get("device_id", type=int)
|
||||
if not device_id:
|
||||
flash("Please select a device.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
device = db.get(Device, device_id)
|
||||
if device is None:
|
||||
flash("Device not found.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
already_here = [b for b in batteries if b.device_id == device.id]
|
||||
retired_sel = [b for b in batteries if b.is_retired()]
|
||||
to_process = [b for b in batteries
|
||||
if b.device_id != device.id and not b.is_retired()]
|
||||
|
||||
if not to_process:
|
||||
flash("No eligible batteries to install.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
free_slots = device.battery_slots - device.installed_count()
|
||||
if len(to_process) > free_slots:
|
||||
flash(
|
||||
f"{device.name} only has {free_slots} free slot(s), "
|
||||
f"but {len(to_process)} need installing.",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
existing_brands = device.installed_brands()
|
||||
new_brands = set(b.brand for b in to_process)
|
||||
if existing_brands and (new_brands - existing_brands):
|
||||
flash(f"Warning: mixing brands in {device.name}.", "warning")
|
||||
|
||||
for b in to_process:
|
||||
b.status = "installed"
|
||||
b.device_id = device.id
|
||||
db.commit()
|
||||
|
||||
msg = (f"Installed {len(to_process)} "
|
||||
f"batter{'y' if len(to_process) == 1 else 'ies'} in {device.name}.")
|
||||
notes = []
|
||||
if already_here:
|
||||
notes.append(f"{len(already_here)} already there")
|
||||
if retired_sel:
|
||||
notes.append(f"{len(retired_sel)} retired skipped")
|
||||
if notes:
|
||||
msg += f" ({', '.join(notes)}.)"
|
||||
flash(msg, "success")
|
||||
|
||||
elif action == "set_field":
|
||||
field_name = request.form.get("field_name", "").strip()
|
||||
field_value = request.form.get("field_value", "").strip() or None
|
||||
|
||||
@@ -185,6 +185,10 @@
|
||||
<form method="post" action="{{ url_for('battery_retire', battery_id=battery.id) }}">
|
||||
<button class="btn btn-secondary" type="submit">Retire Battery</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('battery_unretire', battery_id=battery.id) }}">
|
||||
<button class="btn btn-secondary" type="submit">Unretire Battery</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<a class="btn btn-danger" href="{{ url_for('battery_delete', battery_id=battery.id) }}">Delete Battery</a>
|
||||
|
||||
@@ -113,6 +113,17 @@
|
||||
</span>
|
||||
<button class="btn btn-sm btn-primary" name="action" value="set_field" type="submit">Apply</button>
|
||||
</span>
|
||||
<span style="display:flex;gap:0.35rem;align-items:center;">
|
||||
<select id="bulk-device-select" name="device_id"
|
||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
<option value="">— select device —</option>
|
||||
{% for d in devices %}
|
||||
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary" name="action" value="install_device" type="submit"
|
||||
onclick="return confirmInstallDevice()">Install in device</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
@@ -260,6 +271,23 @@ function applyFilters() {
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
function confirmInstallDevice() {
|
||||
var deviceSel = document.getElementById('bulk-device-select');
|
||||
if (!deviceSel.value) { deviceSel.focus(); return false; }
|
||||
var movers = Array.prototype.filter.call(
|
||||
document.querySelectorAll('.row-cb:checked'),
|
||||
function(cb) { return cb.closest('tr').dataset.status === 'installed'; }
|
||||
);
|
||||
if (movers.length > 0) {
|
||||
var n = movers.length;
|
||||
return confirm(
|
||||
n + ' selected batter' + (n === 1 ? 'y is' : 'ies are') +
|
||||
' already installed elsewhere. Unassign and move to the selected device?'
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function quickAssign(action, batteryId) {
|
||||
var sel = document.getElementById('qas-' + batteryId);
|
||||
if (!sel.value) { sel.focus(); return; }
|
||||
|
||||
@@ -294,6 +294,45 @@ def test_device_install_over_capacity(client):
|
||||
assert b"slot" in resp.data.lower()
|
||||
|
||||
|
||||
def test_bulk_install_device(client):
|
||||
"""Select multiple available batteries and install them into a device."""
|
||||
client.post("/device/add", data={"name": "Box", "battery_slots": "3"})
|
||||
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
|
||||
resp = client.post("/battery/bulk-action",
|
||||
data={"battery_ids": ["1", "2"], "action": "install_device",
|
||||
"device_id": "1"},
|
||||
follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Installed 2" in resp.data
|
||||
|
||||
|
||||
def test_bulk_install_device_over_capacity(client):
|
||||
"""Bulk install is blocked when device lacks free slots."""
|
||||
client.post("/device/add", data={"name": "Box", "battery_slots": "1"})
|
||||
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
|
||||
resp = client.post("/battery/bulk-action",
|
||||
data={"battery_ids": ["1", "2"], "action": "install_device",
|
||||
"device_id": "1"},
|
||||
follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"slot" in resp.data.lower()
|
||||
|
||||
|
||||
def test_bulk_install_device_moves_installed_battery(client):
|
||||
"""Bulk install moves a battery already installed in another device."""
|
||||
client.post("/device/add", data={"name": "Box A", "battery_slots": "2"})
|
||||
client.post("/device/add", data={"name": "Box B", "battery_slots": "2"})
|
||||
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||
client.post("/battery/1/assign", data={"device_id": "1"})
|
||||
resp = client.post("/battery/bulk-action",
|
||||
data={"battery_ids": ["1"], "action": "install_device",
|
||||
"device_id": "2"},
|
||||
follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Installed 1" in resp.data
|
||||
assert b"Box B" in client.get("/battery/1").data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Dashboard — quick-assign
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
Reference in New Issue
Block a user