From 75fe4fe575164f167f60b6394c723506c56a48aa Mon Sep 17 00:00:00 2001 From: Darek Date: Fri, 5 Jun 2026 22:30:11 -0500 Subject: [PATCH] Add sub-component support via self-referential device hierarchy --- app.py | 70 +++++++++++++++++++++++--- models.py | 21 ++++++++ templates/dashboard.html | 4 ++ templates/device_add.html | 8 ++- templates/device_detail.html | 49 ++++++++++++++++++ templates/device_list.html | 9 +++- tests/test_acceptance.py | 96 ++++++++++++++++++++++++++++++++++++ 7 files changed, 248 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index 9051481..8f35ae2 100644 --- a/app.py +++ b/app.py @@ -661,6 +661,12 @@ def create_app(config_object="config"): device_locations = sorted({d.location for d in all_devices if d.location}) device_battery_sizes = sorted({d.battery_size for d in all_devices if d.battery_size}) + # Pre-fill parent for "Add Sub-component" links + prefill_parent_id = request.args.get("parent_id", "").strip() + prefill_parent = None + if prefill_parent_id: + prefill_parent = db.get(Device, int(prefill_parent_id)) if prefill_parent_id.isdigit() else None + if request.method == "POST": name = request.form.get("name", "").strip() slots_raw = request.form.get("battery_slots", "1").strip() @@ -669,12 +675,31 @@ def create_app(config_object="config"): battery_size = request.form.get("battery_size", "").strip() or None location = request.form.get("location", "").strip() or None + # Resolve parent device + parent_id_raw = request.form.get("parent_id", "").strip() + parent_device = None + if parent_id_raw and parent_id_raw.isdigit(): + parent_device = db.get(Device, int(parent_id_raw)) + if not parent_device: + flash("Parent device not found.", "error") + return render_template("device_add.html", + device_types=device_types, + device_locations=device_locations, + device_battery_sizes=device_battery_sizes), 400 + if parent_device.is_subcomponent(): + flash("Cannot nest sub-components more than one level deep.", "error") + return render_template("device_add.html", + device_types=device_types, + device_locations=device_locations, + device_battery_sizes=device_battery_sizes), 400 + if not name: flash("Device name is required.", "error") return render_template("device_add.html", device_types=device_types, device_locations=device_locations, - device_battery_sizes=device_battery_sizes), 400 + device_battery_sizes=device_battery_sizes, + prefill_parent=parent_device), 400 if not battery_size: flash("Battery size is required.", "error") @@ -683,7 +708,8 @@ def create_app(config_object="config"): device_locations=device_locations, device_battery_sizes=device_battery_sizes, form_name=name, form_notes=notes or "", - form_device_type=request.form.get("device_type", "")), 400 + form_device_type=request.form.get("device_type", ""), + prefill_parent=parent_device), 400 try: slots = int(slots_raw) @@ -696,7 +722,8 @@ def create_app(config_object="config"): device_locations=device_locations, device_battery_sizes=device_battery_sizes, form_name=name, form_notes=notes or "", - form_device_type=request.form.get("device_type", "")), 400 + form_device_type=request.form.get("device_type", ""), + prefill_parent=parent_device), 400 if db.query(Device).filter_by(name=name).first(): flash(f"A device named '{name}' already exists.", "error") @@ -706,19 +733,24 @@ def create_app(config_object="config"): device_battery_sizes=device_battery_sizes, form_name=name, form_slots=slots, form_notes=notes or "", - form_device_type=request.form.get("device_type", "")), 400 + form_device_type=request.form.get("device_type", ""), + prefill_parent=parent_device), 400 device = Device(name=name, battery_slots=slots, notes=notes, device_type=device_type, battery_size=battery_size, - location=location) + location=location, + parent_id=parent_device.id if parent_device else None) db.add(device) db.commit() flash(f"Device '{name}' added.", "success") + if parent_device: + return redirect(url_for("device_detail", device_id=parent_device.id)) return redirect(url_for("device_list")) return render_template("device_add.html", device_types=device_types, device_locations=device_locations, - device_battery_sizes=device_battery_sizes) + device_battery_sizes=device_battery_sizes, + prefill_parent=prefill_parent) # ------------------------------------------------------------------ # # Devices — detail @@ -767,6 +799,7 @@ def create_app(config_object="config"): device_types=device_types, device_locations=device_locations, device_battery_sizes=device_battery_sizes, + device_list_all=all_devices, ha_enabled=ha_client.enabled, ha_live_pct=ha_live_pct, logbook_entries=device.logbook_entries) @@ -800,6 +833,23 @@ def create_app(config_object="config"): flash(f"A device named '{name}' already exists.", "error") return redirect(url_for("device_detail", device_id=device_id)) + # Validate parent_id change + parent_id_raw = request.form.get("parent_id", "").strip() + if parent_id_raw and parent_id_raw.isdigit(): + new_parent = db.get(Device, int(parent_id_raw)) + if not new_parent: + flash("Parent device not found.", "error") + return redirect(url_for("device_detail", device_id=device_id)) + if new_parent.id == device_id: + flash("A device cannot be its own parent.", "error") + return redirect(url_for("device_detail", device_id=device_id)) + if new_parent.is_subcomponent(): + flash("Cannot nest sub-components more than one level deep.", "error") + return redirect(url_for("device_detail", device_id=device_id)) + device.parent_id = new_parent.id + else: + device.parent_id = None + device.name = name device.battery_slots = slots device.notes = notes @@ -939,9 +989,15 @@ def create_app(config_object="config"): battery.status = "available" battery.device_id = None name = device.name + child_names = [c.name for c in device.children] + for child in device.children: + child.parent_id = None db.delete(device) db.commit() - flash(f"Device '{name}' deleted. All batteries marked available.", "success") + msg = f"Device '{name}' deleted. All batteries marked available." + if child_names: + msg += f" Sub-components ({', '.join(child_names)}) are now independent devices." + flash(msg, "success") return redirect(url_for("device_list")) # ------------------------------------------------------------------ # diff --git a/models.py b/models.py index 94b072a..b9ea531 100644 --- a/models.py +++ b/models.py @@ -32,6 +32,7 @@ class Device(Base): location = Column(String(100), nullable=True) notes = Column(Text, nullable=True) ha_entity_id = Column(String(100), nullable=True) # e.g. "sensor.tv_remote_battery" + parent_id = Column(Integer, ForeignKey("device.id", ondelete="SET NULL"), nullable=True) batteries = relationship("Battery", back_populates="device") logbook_entries = relationship( @@ -40,6 +41,20 @@ class Device(Base): cascade="all, delete-orphan", single_parent=True, ) + parent = relationship( + "Device", + back_populates="children", + primaryjoin="Device.parent_id == Device.id", + foreign_keys="[Device.parent_id]", + remote_side="[Device.id]", + ) + children = relationship( + "Device", + back_populates="parent", + primaryjoin="Device.parent_id == Device.id", + foreign_keys="[Device.parent_id]", + order_by="Device.name", + ) def installed_count(self): return sum(1 for b in self.batteries if b.status == "installed") @@ -50,6 +65,12 @@ class Device(Base): def has_mixed_brands(self): return len(self.installed_brands()) > 1 + def has_children(self): + return bool(self.children) + + def is_subcomponent(self): + return self.parent_id is not None + def __repr__(self): return f"" diff --git a/templates/dashboard.html b/templates/dashboard.html index cc223c9..2b6ddf1 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -264,6 +264,10 @@ {% if b.device %} + {% if b.device.parent %} + {{ b.device.parent.name }} + / + {% endif %} {{ b.device.name }} {% if b.device.has_mixed_brands() %} ⚠ mixed diff --git a/templates/device_add.html b/templates/device_add.html index e2c9447..ab2bbfa 100644 --- a/templates/device_add.html +++ b/templates/device_add.html @@ -2,10 +2,16 @@ {% block title %}Add Device — Battery Tracker{% endblock %} {% block content %} -

Add Device

+

{% if prefill_parent %}Add Sub-component to {{ prefill_parent.name }}{% else %}Add Device{% endif %}

+ {% if prefill_parent %} + +

+ Adding as a sub-component of {{ prefill_parent.name }}. +

+ {% endif %}
+ ← {{ device.parent.name }} + / {{ device.name }} +
+{% endif %}

{{ device.name }}

@@ -63,6 +69,12 @@ {{ device.notes }} {% endif %} + {% if device.parent %} + + Part of + {{ device.parent.name }} + + {% endif %} {% if ha_enabled and device.ha_entity_id %} HA Live % @@ -82,6 +94,27 @@
+{% if device.children or not device.is_subcomponent() %} +
+ {% if device.children %} +

Sub-components

+ + {% else %} +

Sub-components

+

No sub-components. Batteries are tracked at the device level.

+ {% endif %} + + Add Sub-component +
+{% endif %} +

Install Batteries

{% set free_slots = device.battery_slots - device.installed_count() %} @@ -401,6 +434,19 @@ function addInstallRow() {
+ {% if not device.has_children() %} +
+ + +
+ {% endif %} {% if ha_enabled %}
@@ -426,6 +472,9 @@ function addInstallRow() {

Delete Device

Deleting this device will unassign all installed batteries and mark them available. + {% if device.has_children() %} + Sub-components ({{ device.children|map(attribute='name')|join(', ') }}) will become independent devices. + {% endif %}

diff --git a/templates/device_list.html b/templates/device_list.html index d01de96..0c803ea 100644 --- a/templates/device_list.html +++ b/templates/device_list.html @@ -66,7 +66,14 @@ data-location="{{ d.location or '' }}" data-fill="{{ fill_state }}" data-name="{{ d.name|lower }}"> - {{ d.name }} + + {{ d.name }} + {% if d.parent %} +
{{ d.parent.name }} + {% elif d.has_children() %} +
{{ d.children|length }} sub-component{{ 's' if d.children|length != 1 }} + {% endif %} + {{ d.device_type or '—' }} {{ d.battery_size or '—' }} {{ d.location or '—' }} diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py index dcc4c54..8bd23c0 100644 --- a/tests/test_acceptance.py +++ b/tests/test_acceptance.py @@ -885,3 +885,99 @@ def test_device_charge_all_no_installed(seeded_client): ) assert resp.status_code == 200 assert b"No installed batteries" in resp.data + + +# ------------------------------------------------------------------ # +# Sub-components (self-referential device hierarchy) +# ------------------------------------------------------------------ # + +def _setup_rc_car(client): + """Create RC Car Set with Remote and Car sub-components and 6 AA batteries.""" + client.post("/device/add", data={"name": "RC Car Set", "battery_slots": "6", "battery_size": "AA"}) + # id=1 above; add sub-components with parent_id=1 + client.post("/device/add", data={"name": "Remote", "battery_slots": "2", + "battery_size": "AA", "parent_id": "1"}) + client.post("/device/add", data={"name": "Car", "battery_slots": "4", + "battery_size": "AA", "parent_id": "1"}) + for i in range(1, 7): + client.post("/battery/add", data={"brand": "Eneloop", "label": f"E{i:03d}", "size": "AA"}) + + +def test_add_subcomponent(client): + _setup_rc_car(client) + # Parent detail shows sub-components + resp = client.get("/device/1") + assert resp.status_code == 200 + assert b"Remote" in resp.data + assert b"Car" in resp.data + # Child detail shows breadcrumb back to parent + resp = client.get("/device/2") + assert resp.status_code == 200 + assert b"RC Car Set" in resp.data + assert b"Remote" in resp.data + + +def test_subcomponent_prevents_deep_nesting(client): + _setup_rc_car(client) + # id=2 is "Remote", which already has parent_id=1 + resp = client.post("/device/add", data={"name": "Button", "battery_slots": "1", + "battery_size": "AA", "parent_id": "2"}) + assert resp.status_code == 400 + assert b"one level" in resp.data.lower() or b"nest" in resp.data.lower() + + +def test_subcomponent_install_batteries(client): + _setup_rc_car(client) + # Install 2 batteries into Remote (device id=2) + resp = client.post("/device/2/install-one", data={"battery_id": "1"}, follow_redirects=True) + assert resp.status_code == 200 + resp = client.post("/device/2/install-one", data={"battery_id": "2"}, follow_redirects=True) + assert resp.status_code == 200 + # Remote is now full (2/2); a third battery should be blocked + resp = client.post("/device/2/install-one", data={"battery_id": "3"}, follow_redirects=True) + assert resp.status_code == 200 + assert b"full" in resp.data.lower() + + +def test_dashboard_shows_parent_slash_child(client): + _setup_rc_car(client) + client.post("/device/2/install-one", data={"battery_id": "1"}) + resp = client.get("/", follow_redirects=True) + assert resp.status_code == 200 + assert b"RC Car Set" in resp.data + assert b"Remote" in resp.data + + +def test_delete_parent_promotes_children(client): + _setup_rc_car(client) + client.post("/device/1/delete", follow_redirects=True) + # Child devices still exist + resp = client.get("/device/2") + assert resp.status_code == 200 + resp = client.get("/device/3") + assert resp.status_code == 200 + + +def test_device_without_parent_unchanged(client): + client.post("/device/add", data={"name": "Torch", "battery_slots": "2", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) + resp = client.post("/device/1/install-one", data={"battery_id": "1"}, follow_redirects=True) + assert resp.status_code == 200 + assert b"1 / 2" in resp.data + + +def test_subcomponent_ha_entity_id(client): + client.post("/device/add", data={"name": "Hub", "battery_slots": "1", "battery_size": "AA"}) + resp = client.post( + "/device/add", + data={"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", + "parent_id": "1", "ha_entity_id": "sensor.sensor_a_battery"}, + follow_redirects=True, + ) + # Redirects to parent detail on success + assert resp.status_code == 200 + assert b"Sensor A" in resp.data + # Child detail shows parent breadcrumb + resp = client.get("/device/2") + assert resp.status_code == 200 + assert b"Hub" in resp.data