Scope device uniqueness per parent, null type/location for sub-components, hide sub-components from list, flat battery summary for parent devices

This commit is contained in:
2026-06-09 19:26:31 -05:00
parent 2a54cd8297
commit 23eeeafff7
7 changed files with 340 additions and 42 deletions
+44 -17
View File
@@ -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()
+17 -2
View File
@@ -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"<Device {self.name}>"
+139
View File
@@ -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()
+2
View File
@@ -56,6 +56,7 @@
</div>
</div><!-- #battery-fields -->
{% if not prefill_parent %}
<div class="form-group">
<label>Type</label>
{% 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;">
</div>
{% endif %}
<div class="form-group">
<label for="notes">Notes</label>
+37 -14
View File
@@ -63,6 +63,20 @@
<td style="border:none;">{{ device.location }}</td>
</tr>
{% endif %}
{% if device.is_subcomponent() and device.parent %}
{% if device.parent.device_type %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Type</td>
<td style="border:none;">{{ device.parent.device_type }} <span class="text-muted" style="font-size:0.85em;">(inherited)</span></td>
</tr>
{% endif %}
{% if device.parent.location %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Location</td>
<td style="border:none;">{{ device.parent.location }} <span class="text-muted" style="font-size:0.85em;">(inherited)</span></td>
</tr>
{% endif %}
{% endif %}
{% if device.notes %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
@@ -116,24 +130,30 @@
{% endif %}
{% if device.has_children() %}
<div class="card">
<h2>Batteries in Sub-components</h2>
{% for child in device.children %}
{% set child_installed = child.batteries | selectattr('status', 'eq', 'installed') | list %}
<h3 style="margin:0.75rem 0 0.4rem;font-size:1rem;">
<a href="{{ url_for('device_detail', device_id=child.id) }}">{{ child.name }}</a>
<small class="text-muted" style="font-weight:normal;">&nbsp;{{ child_installed|length }}/{{ child.battery_slots }}</small>
</h3>
{% if child_installed %}
<div class="card" id="installed">
<h2>Installed Batteries</h2>
{% if flat_installed %}
<div class="table-wrap">
<table class="responsive-table">
<thead><tr><th>Label</th><th>Brand</th>{% if ha_enabled %}<th>Bat %</th>{% endif %}<th>Last Charged</th></tr></thead>
<thead><tr>
<th>Component</th><th>Label</th><th>Brand</th>
{% if ha_enabled %}<th>Bat %</th>{% endif %}
<th>Last Charged</th>
</tr></thead>
<tbody>
{% for b in child_installed %}
{% for b, comp_name, comp_id in flat_installed %}
<tr>
<td data-label="Component"><a href="{{ url_for('device_detail', device_id=comp_id) }}">{{ comp_name }}</a></td>
<td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
<td data-label="Brand">{{ b.brand }}</td>
{% if ha_enabled %}<td data-label="Bat %">{% if b.battery_percentage is not none %}{{ b.battery_percentage }}%{% else %}—{% endif %}</td>{% endif %}
{% if ha_enabled %}
<td data-label="Bat %">
{% if b.battery_percentage is not none %}
{% if b.battery_percentage < 20 %}<span class="badge badge-warning">⚠ {{ b.battery_percentage }}%</span>
{% else %}{{ b.battery_percentage }}%{% endif %}
{% else %}—{% endif %}
</td>
{% endif %}
<td data-label="Last Charged" class="text-muted">{{ b.charge_logs[-1].charged_date if b.charge_logs else '—' }}</td>
</tr>
{% endfor %}
@@ -141,9 +161,8 @@
</table>
</div>
{% else %}
<p class="text-muted" style="margin:0 0 0.5rem;">No batteries installed.</p>
<p class="text-muted">No batteries installed across sub-components.</p>
{% endif %}
{% endfor %}
</div>
{% endif %}
@@ -407,6 +426,7 @@ function addInstallRow() {
<input type="number" id="edit-slots" name="battery_slots" value="{{ device.battery_slots }}" min="1" required>
</div>
{% endif %}
{% if not device.is_subcomponent() %}
<div class="form-group">
<label>Type</label>
{% 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;">
</div>
{% endif %}
{% if not device.has_children() %}
<div class="form-group">
<label>Battery Size</label>
@@ -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;">
</div>
{% endif %}
{% if not device.is_subcomponent() %}
<div class="form-group">
<label>Location</label>
<select id="edit-location-select" onchange="editLocationSelectChanged(this)">
@@ -469,6 +491,7 @@ function addInstallRow() {
placeholder="e.g. Living Room, Bedroom"
style="display:{% if device.location and device.location not in device_locations|default([]) %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div>
{% endif %}
<div class="form-group">
<label for="edit-notes">Notes</label>
+8 -9
View File
@@ -57,9 +57,10 @@
</thead>
<tbody>
{% for d in devices %}
{% set installed = d.installed_count() %}
{% if installed == 0 %}{% set fill_state = 'empty' %}
{% elif installed >= d.battery_slots %}{% set fill_state = 'full' %}
{% set installed = d.effective_installed_count() %}
{% set total_slots = d.effective_slots() %}
{% if installed == 0 or total_slots == 0 %}{% set fill_state = 'empty' %}
{% elif installed >= total_slots %}{% set fill_state = 'full' %}
{% else %}{% set fill_state = 'partial' %}{% endif %}
<tr data-type="{{ d.device_type or '' }}"
data-battery-size="{{ d.battery_size or '' }}"
@@ -68,19 +69,17 @@
data-name="{{ d.name|lower }}">
<td data-label="Device">
<a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a>
{% if d.parent %}
<br><small class="text-muted"><a href="{{ url_for('device_detail', device_id=d.parent.id) }}">{{ d.parent.name }}</a></small>
{% elif d.has_children() %}
{% if d.has_children() %}
<br><small class="text-muted">{{ d.children|length }} sub-component{{ 's' if d.children|length != 1 }}</small>
{% endif %}
</td>
<td data-label="Type">{{ d.device_type or '—' }}</td>
<td data-label="Size">{{ d.battery_size or '—' }}</td>
<td data-label="Location">{{ d.location or '—' }}</td>
<td data-label="Slots">{{ d.battery_slots }}</td>
<td data-label="Slots">{{ total_slots }}</td>
<td data-label="Installed">
{{ installed }} / {{ d.battery_slots }}
{% if installed >= d.battery_slots %}
{{ installed }} / {{ total_slots }}
{% if total_slots > 0 and installed >= total_slots %}
<span class="badge badge-retired">Full</span>
{% endif %}
</td>
+93
View File
@@ -975,3 +975,96 @@ def test_subcomponent_ha_entity_id(client):
resp = client.get("/device/2")
assert resp.status_code == 200
assert b"Hub" in resp.data
# ------------------------------------------------------------------ #
# Sub-component hierarchy — parent_key, null type/location, list hiding
# ------------------------------------------------------------------ #
def _setup_parent_with_two_children(client):
"""Hub (top-level, Sensor type, Living Room) with Sensor A and Sensor B."""
client.post("/device/add", data={
"name": "Hub", "battery_slots": "0", "battery_size": "",
"device_type": "Sensor", "location": "Living Room",
}, follow_redirects=True)
client.post("/device/add", data={
"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "1",
}, follow_redirects=True)
client.post("/device/add", data={
"name": "Sensor B", "battery_slots": "1", "battery_size": "AA", "parent_id": "1",
}, follow_redirects=True)
def test_device_list_hides_subcomponents(client):
_setup_parent_with_two_children(client)
resp = client.get("/device/")
assert resp.status_code == 200
assert b"Hub" in resp.data
assert b"Sensor A" not in resp.data
assert b"Sensor B" not in resp.data
def test_subcomponent_type_location_null(client):
_setup_parent_with_two_children(client)
# Sub-component detail shows inherited type/location from parent
resp = client.get("/device/2")
assert resp.status_code == 200
assert b"Sensor" in resp.data
assert b"Living Room" in resp.data
assert b"inherited" in resp.data
# Submitting type/location in edit for sub-component should have no effect
client.post("/device/2/edit", data={
"name": "Sensor A", "battery_slots": "1",
"device_type": "Remote Control", "location": "Bedroom",
"parent_id": "1", # keep sub-component relationship
})
resp2 = client.get("/device/2")
assert b"Remote Control" not in resp2.data
assert b"Bedroom" not in resp2.data
def test_subcomponent_name_unique_per_parent(client):
_setup_parent_with_two_children(client)
# Duplicate top-level name → 400
resp = client.post("/device/add", data={
"name": "Hub", "battery_slots": "0", "battery_size": "",
})
assert resp.status_code == 400
assert b"already exists" in resp.data
# Duplicate child name within same parent → 400
resp = client.post("/device/add", data={
"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "1",
})
assert resp.status_code == 400
assert b"already exists" in resp.data
# Same child name under a DIFFERENT parent → success
client.post("/device/add", data={
"name": "Hub 2", "battery_slots": "0", "battery_size": "",
}) # id=4
resp = client.post("/device/add", data={
"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "4",
}, follow_redirects=True)
assert resp.status_code == 200
def test_parent_battery_summary_flat(client):
_setup_parent_with_two_children(client)
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"}) # ids 1, 2
client.post("/device/2/install-one", data={"battery_id": "1"})
client.post("/device/3/install-one", data={"battery_id": "2"})
resp = client.get("/device/1")
assert resp.status_code == 200
# Flat table has Component column heading
assert b"Component" in resp.data
# Both batteries appear
assert b"Eneloop 001" in resp.data
assert b"Eneloop 002" in resp.data
# Both child names linked in the component column
assert b"Sensor A" in resp.data
assert b"Sensor B" in resp.data
# Only one "Installed Batteries" heading
assert resp.data.count(b"Installed Batteries") == 1