From 0869ef3d5e93796ce78fa687a293865907b9ed45 Mon Sep 17 00:00:00 2001 From: Darek Date: Sun, 12 Apr 2026 21:08:48 -0500 Subject: [PATCH] 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 --- app.py | 66 +++++++++++++++++++++++++++++++++++ templates/battery_detail.html | 4 +++ templates/dashboard.html | 28 +++++++++++++++ tests/test_acceptance.py | 39 +++++++++++++++++++++ 4 files changed, 137 insertions(+) diff --git a/app.py b/app.py index 496684f..525376b 100644 --- a/app.py +++ b/app.py @@ -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//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 diff --git a/templates/battery_detail.html b/templates/battery_detail.html index f6aaba7..1d8a2fa 100644 --- a/templates/battery_detail.html +++ b/templates/battery_detail.html @@ -185,6 +185,10 @@
+ {% else %} +
+ +
{% endif %} Delete Battery diff --git a/templates/dashboard.html b/templates/dashboard.html index 739e649..1c8feb1 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -113,6 +113,17 @@ + + + +
@@ -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; } diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py index a50456d..921a756 100644 --- a/tests/test_acceptance.py +++ b/tests/test_acceptance.py @@ -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 # ------------------------------------------------------------------ #