From 23eeeafff706b9d64020b1ce1d77b9b7d383a542 Mon Sep 17 00:00:00 2001 From: Darek Date: Tue, 9 Jun 2026 19:26:31 -0500 Subject: [PATCH] Scope device uniqueness per parent, null type/location for sub-components, hide sub-components from list, flat battery summary for parent devices --- app.py | 61 ++++++++++----- models.py | 19 ++++- sbin/migrate_parent_key.py | 139 +++++++++++++++++++++++++++++++++++ templates/device_add.html | 2 + templates/device_detail.html | 51 +++++++++---- templates/device_list.html | 17 ++--- tests/test_acceptance.py | 93 +++++++++++++++++++++++ 7 files changed, 340 insertions(+), 42 deletions(-) create mode 100644 sbin/migrate_parent_key.py diff --git a/app.py b/app.py index c946abc..f9f25d0 100644 --- a/app.py +++ b/app.py @@ -650,7 +650,7 @@ def create_app(config_object="config"): @app.route("/device/") def device_list(): - devices = db.query(Device).order_by(Device.name).all() + devices = db.query(Device).filter(Device.parent_id == None).order_by(Device.name).all() device_types = sorted({d.device_type for d in devices if d.device_type}) device_locations = sorted({d.location for d in devices if d.location}) device_battery_sizes = sorted({d.battery_size for d in devices if d.battery_size}) @@ -733,8 +733,11 @@ def create_app(config_object="config"): 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") + parent_key_val = parent_device.id if parent_device else -1 + if db.query(Device).filter( + Device.parent_key == parent_key_val, Device.name == name + ).first(): + flash(f"A device named '{name}' already exists here.", "error") return render_template("device_add.html", device_types=device_types, device_locations=device_locations, @@ -744,10 +747,13 @@ def create_app(config_object="config"): form_device_type=request.form.get("device_type", ""), prefill_parent=parent_device), 400 + is_sub = parent_device is not None device = Device(name=name, battery_slots=slots, notes=notes, - device_type=device_type, battery_size=battery_size, - location=location, - parent_id=parent_device.id if parent_device else None) + device_type=None if is_sub else device_type, + battery_size=battery_size, + location=None if is_sub else location, + parent_id=parent_device.id if parent_device else None, + parent_key=parent_key_val) db.add(device) db.commit() flash(f"Device '{name}' added.", "success") @@ -758,9 +764,7 @@ def create_app(config_object="config"): return render_template("device_add.html", device_types=device_types, device_locations=device_locations, device_battery_sizes=device_battery_sizes, - prefill_parent=prefill_parent, - form_device_type=prefill_parent.device_type if prefill_parent else None, - form_location=prefill_parent.location if prefill_parent else None) + prefill_parent=prefill_parent) # ------------------------------------------------------------------ # # Devices — detail @@ -804,6 +808,12 @@ def create_app(config_object="config"): changed = True if changed: db.commit() + flat_installed = [] + if device.has_children(): + for child in device.children: + for b in child.batteries: + if b.status == "installed": + flat_installed.append((b, child.name, child.id)) return render_template("device_detail.html", device=device, brands=brands, available_batteries=available_batteries, device_types=device_types, @@ -812,7 +822,8 @@ def create_app(config_object="config"): device_list_all=all_devices, ha_enabled=ha_client.enabled, ha_live_pct=ha_live_pct, - logbook_entries=device.logbook_entries) + logbook_entries=device.logbook_entries, + flat_installed=flat_installed) # ------------------------------------------------------------------ # # Devices — edit @@ -840,13 +851,20 @@ def create_app(config_object="config"): except ValueError: flash("Battery slots must be a positive integer.", "error") return redirect(url_for("device_detail", device_id=device_id)) - existing = db.query(Device).filter_by(name=name).first() - if existing and existing.id != device_id: - flash(f"A device named '{name}' already exists.", "error") + # Determine new parent key for scoped uniqueness check + parent_id_raw = request.form.get("parent_id", "").strip() + new_parent_key_val = int(parent_id_raw) if (parent_id_raw and parent_id_raw.isdigit()) else -1 + + existing = db.query(Device).filter( + Device.parent_key == new_parent_key_val, + Device.name == name, + Device.id != device_id, + ).first() + if existing: + flash(f"A device named '{name}' already exists here.", "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: @@ -866,12 +884,19 @@ def create_app(config_object="config"): ) return redirect(url_for("device_detail", device_id=device_id)) device.parent_id = new_parent.id + device.parent_key = new_parent.id else: device.parent_id = None + device.parent_key = -1 device.name = name device.notes = notes - device.device_type = device_type + if device.is_subcomponent(): + device.device_type = None + device.location = None + else: + device.device_type = device_type + device.location = request.form.get("location", "").strip() or None if device.has_children(): device.battery_slots = 0 device.battery_size = None @@ -880,7 +905,6 @@ def create_app(config_object="config"): new_battery_size = request.form.get("battery_size", "").strip() or None if new_battery_size is not None: device.battery_size = new_battery_size - device.location = request.form.get("location", "").strip() or None device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None db.commit() flash("Device updated.", "success") @@ -1327,7 +1351,9 @@ def create_app(config_object="config"): if not name: devices_skipped += 1 continue - existing = db.query(Device).filter_by(name=name).first() + existing = db.query(Device).filter( + Device.parent_key == -1, Device.name == name + ).first() if existing: if old_id is not None: device_id_map[old_id] = existing.id @@ -1341,6 +1367,7 @@ def create_app(config_object="config"): location = d.get("location") or None, ha_entity_id = d.get("ha_entity_id") or None, notes = d.get("notes") or None, + parent_key = -1, ) db.add(new_dev) db.flush() diff --git a/models.py b/models.py index dad379a..7308075 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table +from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table, UniqueConstraint from sqlalchemy.orm import declarative_base, relationship Base = declarative_base() @@ -25,7 +25,7 @@ class Device(Base): __tablename__ = "device" id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String(100), nullable=False, unique=True) + name = Column(String(100), nullable=False) battery_slots = Column(Integer, nullable=False, default=1) device_type = Column(String(50), nullable=True) battery_size = Column(String(20), nullable=True) # AA, AAA, 9V, CR2032 …; null for parent-only devices @@ -33,6 +33,11 @@ class Device(Base): 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) + parent_key = Column(Integer, nullable=False, default=-1) # -1 for top-level, parent_id for sub-components + + __table_args__ = ( + UniqueConstraint("parent_key", "name", name="uq_device_parent_key_name"), + ) batteries = relationship("Battery", back_populates="device") logbook_entries = relationship( @@ -71,6 +76,16 @@ class Device(Base): def is_subcomponent(self): return self.parent_id is not None + def effective_installed_count(self): + if self.has_children(): + return sum(c.installed_count() for c in self.children) + return self.installed_count() + + def effective_slots(self): + if self.has_children(): + return sum(c.battery_slots for c in self.children) + return self.battery_slots + def __repr__(self): return f"" diff --git a/sbin/migrate_parent_key.py b/sbin/migrate_parent_key.py new file mode 100644 index 0000000..d32c1d8 --- /dev/null +++ b/sbin/migrate_parent_key.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +sbin/migrate_parent_key.py +========================== +Adds parent_key column, changes uniqueness from UNIQUE(name) to +UNIQUE(parent_key, name), and clears device_type/location for sub-components. + +SQLite: recreates the device table (required to change constraints). +MariaDB: uses ALTER TABLE. Check the index name with: + SHOW INDEX FROM device WHERE Column_name = 'name'; + +Usage: + python sbin/migrate_parent_key.py # SQLite (batteries.db) + MARIADB_URL='mysql+...' python sbin/migrate_parent_key.py +""" + +import os +import shutil +import sys +from datetime import date +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def snapshot(src: Path) -> None: + dst = src.parent / f"{src.name}.{date.today().isoformat()}.snapshot" + shutil.copy2(src, dst) + print(f"Snapshot: {dst.name}") + + +def migrate_sqlite() -> None: + import sqlite3 + + db_path = REPO_ROOT / "batteries.db" + if not db_path.exists(): + print("batteries.db not found — nothing to migrate.") + return + + snapshot(db_path) + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys = OFF") + + # Check if parent_key already exists (idempotency) + cols = {row[1] for row in conn.execute("PRAGMA table_info(device)")} + if "parent_key" in cols: + print("parent_key already present — running data-only fixup.") + conn.execute("UPDATE device SET parent_key = COALESCE(parent_id, -1)") + conn.execute("UPDATE device SET device_type = NULL, location = NULL WHERE parent_id IS NOT NULL") + conn.commit() + conn.close() + print("Done.") + return + + conn.execute("ALTER TABLE device RENAME TO device_old") + + conn.execute(""" + CREATE TABLE device ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + battery_slots INTEGER NOT NULL DEFAULT 1, + device_type VARCHAR(50), + battery_size VARCHAR(20), + location VARCHAR(100), + notes TEXT, + ha_entity_id VARCHAR(100), + parent_id INTEGER REFERENCES device(id) ON DELETE SET NULL, + parent_key INTEGER NOT NULL DEFAULT -1, + CONSTRAINT uq_device_parent_key_name UNIQUE (parent_key, name) + ) + """) + + conn.execute(""" + INSERT INTO device + (id, name, battery_slots, device_type, battery_size, location, + notes, ha_entity_id, parent_id, parent_key) + SELECT + id, name, battery_slots, + CASE WHEN parent_id IS NOT NULL THEN NULL ELSE device_type END, + battery_size, + CASE WHEN parent_id IS NOT NULL THEN NULL ELSE location END, + notes, ha_entity_id, parent_id, + COALESCE(parent_id, -1) + FROM device_old + """) + + conn.execute("DROP TABLE device_old") + conn.execute("PRAGMA foreign_keys = ON") + conn.commit() + + count = conn.execute("SELECT COUNT(*) FROM device").fetchone()[0] + print(f"SQLite migration complete — {count} device(s) migrated.") + conn.close() + + +def migrate_mariadb(url: str) -> None: + from sqlalchemy import create_engine, text + + engine = create_engine(url) + with engine.connect() as conn: + # Add column (skip if already present) + try: + conn.execute(text( + "ALTER TABLE device ADD COLUMN parent_key INT NOT NULL DEFAULT -1" + )) + except Exception: + print("parent_key column already exists — skipping ADD COLUMN.") + + conn.execute(text("UPDATE device SET parent_key = COALESCE(parent_id, -1)")) + conn.execute(text( + "UPDATE device SET device_type = NULL, location = NULL WHERE parent_id IS NOT NULL" + )) + + # Drop old UNIQUE index on name — check actual name first: + # SHOW INDEX FROM device WHERE Column_name = 'name'; + # Common names: 'name' or 'ix_device_name' + for idx_name in ("name", "ix_device_name"): + try: + conn.execute(text(f"ALTER TABLE device DROP INDEX `{idx_name}`")) + print(f"Dropped index '{idx_name}'.") + break + except Exception: + continue + + conn.execute(text( + "ALTER TABLE device ADD CONSTRAINT uq_device_parent_key_name " + "UNIQUE (parent_key, name)" + )) + conn.commit() + + print("MariaDB migration complete.") + + +if __name__ == "__main__": + mariadb_url = os.environ.get("MARIADB_URL", "").strip() + if mariadb_url: + migrate_mariadb(mariadb_url) + else: + migrate_sqlite() diff --git a/templates/device_add.html b/templates/device_add.html index b561574..bd06fce 100644 --- a/templates/device_add.html +++ b/templates/device_add.html @@ -56,6 +56,7 @@ + {% if not prefill_parent %}
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %} @@ -94,6 +95,7 @@ placeholder="e.g. Living Room, Bedroom" style="display:none;margin-top:0.4rem;">
+ {% endif %}
diff --git a/templates/device_detail.html b/templates/device_detail.html index 810d003..112491a 100644 --- a/templates/device_detail.html +++ b/templates/device_detail.html @@ -63,6 +63,20 @@ {{ device.location }} {% endif %} + {% if device.is_subcomponent() and device.parent %} + {% if device.parent.device_type %} + + Type + {{ device.parent.device_type }} (inherited) + + {% endif %} + {% if device.parent.location %} + + Location + {{ device.parent.location }} (inherited) + + {% endif %} + {% endif %} {% if device.notes %} Notes @@ -116,24 +130,30 @@ {% endif %} {% if device.has_children() %} -
-

Batteries in Sub-components

- {% for child in device.children %} - {% set child_installed = child.batteries | selectattr('status', 'eq', 'installed') | list %} -

- {{ child.name }} -  {{ child_installed|length }}/{{ child.battery_slots }} -

- {% if child_installed %} +
+

Installed Batteries

+ {% if flat_installed %}
- {% if ha_enabled %}{% endif %} + + + {% if ha_enabled %}{% endif %} + + - {% for b in child_installed %} + {% for b, comp_name, comp_id in flat_installed %} + - {% if ha_enabled %}{% endif %} + {% if ha_enabled %} + + {% endif %} {% endfor %} @@ -141,9 +161,8 @@
LabelBrandBat %Last Charged
ComponentLabelBrandBat %Last Charged
{{ comp_name }} {{ b.label }} {{ b.brand }}{% if b.battery_percentage is not none %}{{ b.battery_percentage }}%{% else %}—{% endif %} + {% if b.battery_percentage is not none %} + {% if b.battery_percentage < 20 %}⚠ {{ b.battery_percentage }}% + {% else %}{{ b.battery_percentage }}%{% endif %} + {% else %}—{% endif %} + {{ b.charge_logs[-1].charged_date if b.charge_logs else '—' }}
{% else %} -

No batteries installed.

+

No batteries installed across sub-components.

{% endif %} - {% endfor %}
{% endif %} @@ -407,6 +426,7 @@ function addInstallRow() {
{% endif %} + {% if not device.is_subcomponent() %}
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %} @@ -427,6 +447,7 @@ function addInstallRow() { placeholder="Enter device type" style="display:{% if device.device_type and device.device_type not in _preset_types %}''{% else %}none{% endif %};margin-top:0.4rem;">
+ {% endif %} {% if not device.has_children() %}
@@ -452,6 +473,7 @@ function addInstallRow() { style="display:{% if device.battery_size and device.battery_size not in _preset_sizes %}''{% else %}none{% endif %};margin-top:0.4rem;">
{% endif %} + {% if not device.is_subcomponent() %}