Compare commits

...
2 Commits
7 changed files with 421 additions and 49 deletions
+53 -18
View File
@@ -650,7 +650,7 @@ def create_app(config_object="config"):
@app.route("/device/") @app.route("/device/")
def device_list(): 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_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_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}) 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", ""), form_device_type=request.form.get("device_type", ""),
prefill_parent=parent_device), 400 prefill_parent=parent_device), 400
if db.query(Device).filter_by(name=name).first(): parent_key_val = parent_device.id if parent_device else -1
flash(f"A device named '{name}' already exists.", "error") 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", return render_template("device_add.html",
device_types=device_types, device_types=device_types,
device_locations=device_locations, device_locations=device_locations,
@@ -744,23 +747,24 @@ def create_app(config_object="config"):
form_device_type=request.form.get("device_type", ""), form_device_type=request.form.get("device_type", ""),
prefill_parent=parent_device), 400 prefill_parent=parent_device), 400
is_sub = parent_device is not None
device = Device(name=name, battery_slots=slots, notes=notes, device = Device(name=name, battery_slots=slots, notes=notes,
device_type=device_type, battery_size=battery_size, device_type=None if is_sub else device_type,
location=location, battery_size=battery_size,
parent_id=parent_device.id if parent_device else None) 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.add(device)
db.commit() db.commit()
flash(f"Device '{name}' added.", "success") flash(f"Device '{name}' added.", "success")
if parent_device: if parent_device:
return redirect(url_for("device_detail", device_id=parent_device.id)) return redirect(url_for("device_detail", device_id=parent_device.id))
return redirect(url_for("device_list")) return redirect(url_for("device_detail", device_id=device.id))
return render_template("device_add.html", device_types=device_types, return render_template("device_add.html", device_types=device_types,
device_locations=device_locations, device_locations=device_locations,
device_battery_sizes=device_battery_sizes, device_battery_sizes=device_battery_sizes,
prefill_parent=prefill_parent, 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)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Devices — detail # Devices — detail
@@ -804,6 +808,19 @@ def create_app(config_object="config"):
changed = True changed = True
if changed: if changed:
db.commit() db.commit()
flat_installed = []
children_avail = {}
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))
avail_q = db.query(Battery).filter_by(status="available")
if child.battery_size:
avail_q = avail_q.filter(
(Battery.size == child.battery_size) | (Battery.size == None)
)
children_avail[child.id] = avail_q.order_by(Battery.label).all()
return render_template("device_detail.html", device=device, brands=brands, return render_template("device_detail.html", device=device, brands=brands,
available_batteries=available_batteries, available_batteries=available_batteries,
device_types=device_types, device_types=device_types,
@@ -812,7 +829,9 @@ def create_app(config_object="config"):
device_list_all=all_devices, device_list_all=all_devices,
ha_enabled=ha_client.enabled, ha_enabled=ha_client.enabled,
ha_live_pct=ha_live_pct, ha_live_pct=ha_live_pct,
logbook_entries=device.logbook_entries) logbook_entries=device.logbook_entries,
flat_installed=flat_installed,
children_avail=children_avail)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Devices — edit # Devices — edit
@@ -840,13 +859,20 @@ def create_app(config_object="config"):
except ValueError: except ValueError:
flash("Battery slots must be a positive integer.", "error") flash("Battery slots must be a positive integer.", "error")
return redirect(url_for("device_detail", device_id=device_id)) return redirect(url_for("device_detail", device_id=device_id))
existing = db.query(Device).filter_by(name=name).first() # Determine new parent key for scoped uniqueness check
if existing and existing.id != device_id: parent_id_raw = request.form.get("parent_id", "").strip()
flash(f"A device named '{name}' already exists.", "error") 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)) return redirect(url_for("device_detail", device_id=device_id))
# Validate parent_id change # Validate parent_id change
parent_id_raw = request.form.get("parent_id", "").strip()
if parent_id_raw and parent_id_raw.isdigit(): if parent_id_raw and parent_id_raw.isdigit():
new_parent = db.get(Device, int(parent_id_raw)) new_parent = db.get(Device, int(parent_id_raw))
if not new_parent: if not new_parent:
@@ -866,12 +892,19 @@ def create_app(config_object="config"):
) )
return redirect(url_for("device_detail", device_id=device_id)) return redirect(url_for("device_detail", device_id=device_id))
device.parent_id = new_parent.id device.parent_id = new_parent.id
device.parent_key = new_parent.id
else: else:
device.parent_id = None device.parent_id = None
device.parent_key = -1
device.name = name device.name = name
device.notes = notes 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(): if device.has_children():
device.battery_slots = 0 device.battery_slots = 0
device.battery_size = None device.battery_size = None
@@ -880,7 +913,6 @@ def create_app(config_object="config"):
new_battery_size = request.form.get("battery_size", "").strip() or None new_battery_size = request.form.get("battery_size", "").strip() or None
if new_battery_size is not None: if new_battery_size is not None:
device.battery_size = new_battery_size 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 device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None
db.commit() db.commit()
flash("Device updated.", "success") flash("Device updated.", "success")
@@ -1327,7 +1359,9 @@ def create_app(config_object="config"):
if not name: if not name:
devices_skipped += 1 devices_skipped += 1
continue 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 existing:
if old_id is not None: if old_id is not None:
device_id_map[old_id] = existing.id device_id_map[old_id] = existing.id
@@ -1341,6 +1375,7 @@ def create_app(config_object="config"):
location = d.get("location") or None, location = d.get("location") or None,
ha_entity_id = d.get("ha_entity_id") or None, ha_entity_id = d.get("ha_entity_id") or None,
notes = d.get("notes") or None, notes = d.get("notes") or None,
parent_key = -1,
) )
db.add(new_dev) db.add(new_dev)
db.flush() db.flush()
+17 -2
View File
@@ -1,6 +1,6 @@
from datetime import datetime 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 from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base() Base = declarative_base()
@@ -25,7 +25,7 @@ class Device(Base):
__tablename__ = "device" __tablename__ = "device"
id = Column(Integer, primary_key=True, autoincrement=True) 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) battery_slots = Column(Integer, nullable=False, default=1)
device_type = Column(String(50), nullable=True) device_type = Column(String(50), nullable=True)
battery_size = Column(String(20), nullable=True) # AA, AAA, 9V, CR2032 …; null for parent-only devices 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) notes = Column(Text, nullable=True)
ha_entity_id = Column(String(100), nullable=True) # e.g. "sensor.tv_remote_battery" 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_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") batteries = relationship("Battery", back_populates="device")
logbook_entries = relationship( logbook_entries = relationship(
@@ -71,6 +76,16 @@ class Device(Base):
def is_subcomponent(self): def is_subcomponent(self):
return self.parent_id is not None 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): def __repr__(self):
return f"<Device {self.name}>" 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>
</div><!-- #battery-fields --> </div><!-- #battery-fields -->
{% if not prefill_parent %}
<div class="form-group"> <div class="form-group">
<label>Type</label> <label>Type</label>
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %} {% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
@@ -94,6 +95,7 @@
placeholder="e.g. Living Room, Bedroom" placeholder="e.g. Living Room, Bedroom"
style="display:none;margin-top:0.4rem;"> style="display:none;margin-top:0.4rem;">
</div> </div>
{% endif %}
<div class="form-group"> <div class="form-group">
<label for="notes">Notes</label> <label for="notes">Notes</label>
+109 -20
View File
@@ -63,6 +63,20 @@
<td style="border:none;">{{ device.location }}</td> <td style="border:none;">{{ device.location }}</td>
</tr> </tr>
{% endif %} {% 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 %} {% if device.notes %}
<tr> <tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td> <td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
@@ -98,13 +112,79 @@
<div class="card"> <div class="card">
{% if device.children %} {% if device.children %}
<h2>Sub-components</h2> <h2>Sub-components</h2>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.75rem;"> <div style="display:flex;flex-direction:column;gap:0.5rem;margin-bottom:0.75rem;">
{% for child in device.children %} {% for child in device.children %}
<a href="{{ url_for('device_detail', device_id=child.id) }}" {% set child_free = child.battery_slots - child.installed_count() %}
style="display:block;padding:0.6rem 0.9rem;border:1px solid var(--border);border-radius:6px;text-decoration:none;min-width:140px;background:var(--bg-card);"> {% set child_batteries = children_avail.get(child.id, []) %}
<strong>{{ child.name }}</strong><br> <details style="border:1px solid var(--border);border-radius:6px;background:var(--bg-card);">
<small class="text-muted">{{ child.installed_count() }}/{{ child.battery_slots }} slots &middot; {{ child.battery_size }}{% if child.ha_entity_id %} &middot; HA{% endif %}</small> <summary style="padding:0.6rem 0.9rem;cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;">
</a> <span>
<strong>{{ child.name }}</strong>
<small class="text-muted" style="margin-left:0.5rem;">{{ child.installed_count() }}/{{ child.battery_slots }} slots &middot; {{ child.battery_size }}{% if child.ha_entity_id %} &middot; HA{% endif %}</small>
</span>
<span style="display:flex;gap:0.5rem;align-items:center;">
{% if child_free > 0 and child_batteries %}<small class="text-muted">{{ child_batteries|length }} available</small>{% endif %}
<a href="{{ url_for('device_detail', device_id=child.id) }}" onclick="event.stopPropagation();" style="font-size:0.8rem;">View</a>
</span>
</summary>
<div style="padding:0.6rem 0.9rem;border-top:1px solid var(--border);">
{% if child_free <= 0 %}
<p class="text-muted" style="margin:0;">No free slots.</p>
{% elif not child_batteries %}
<p class="text-muted" style="margin:0;">No compatible batteries available.</p>
{% else %}
<form method="post" action="{{ url_for('device_install_batch', device_id=child.id) }}">
<div style="margin-bottom:0.4rem;">
<label style="font-size:0.85rem;cursor:pointer;">
<input type="checkbox" id="select-all-child-{{ child.id }}" onchange="toggleAllChild{{ child.id }}(this)"> Select all
</label>
</div>
<div style="max-height:180px;overflow-y:auto;border:1px solid #cbd5e1;border-radius:4px;padding:0.4rem 0.6rem;">
{% for b in child_batteries %}
<div>
<label style="font-size:0.9rem;cursor:pointer;">
<input type="checkbox" class="child-bat-{{ child.id }}" name="battery_ids" value="{{ b.id }}">
{{ b.label }} — {{ b.brand }}{% if b.battery_percentage is not none %} ({{ b.battery_percentage }}%){% endif %}
</label>
</div>
{% endfor %}
</div>
<button class="btn btn-sm btn-primary" type="submit" style="margin-top:0.5rem;">Install Selected</button>
</form>
<script>
(function() {
var FREE_{{ child.id }} = {{ child_free }};
var queue_{{ child.id }} = [];
document.querySelectorAll('.child-bat-{{ child.id }}').forEach(function(cb) {
cb.addEventListener('change', function() {
if (cb.checked) {
queue_{{ child.id }}.push(cb);
if (queue_{{ child.id }}.length > FREE_{{ child.id }}) {
queue_{{ child.id }}.shift().checked = false;
}
} else {
queue_{{ child.id }} = queue_{{ child.id }}.filter(function(c) { return c !== cb; });
}
document.getElementById('select-all-child-{{ child.id }}').checked = false;
});
});
window['toggleAllChild{{ child.id }}'] = function(masterCb) {
var all = Array.from(document.querySelectorAll('.child-bat-{{ child.id }}'));
if (masterCb.checked) {
queue_{{ child.id }} = [];
all.forEach(function(c) { c.checked = false; });
all.slice(0, FREE_{{ child.id }}).forEach(function(c) { c.checked = true; queue_{{ child.id }}.push(c); });
if (all.length > FREE_{{ child.id }}) masterCb.checked = false;
} else {
all.forEach(function(c) { c.checked = false; });
queue_{{ child.id }} = [];
}
};
})();
</script>
{% endif %}
</div>
</details>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
@@ -116,24 +196,30 @@
{% endif %} {% endif %}
{% if device.has_children() %} {% if device.has_children() %}
<div class="card"> <div class="card" id="installed">
<h2>Batteries in Sub-components</h2> <h2>Installed Batteries</h2>
{% for child in device.children %} {% if flat_installed %}
{% 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="table-wrap"> <div class="table-wrap">
<table class="responsive-table"> <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> <tbody>
{% for b in child_installed %} {% for b, comp_name, comp_id in flat_installed %}
<tr> <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="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
<td data-label="Brand">{{ b.brand }}</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> <td data-label="Last Charged" class="text-muted">{{ b.charge_logs[-1].charged_date if b.charge_logs else '—' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -141,9 +227,8 @@
</table> </table>
</div> </div>
{% else %} {% 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 %} {% endif %}
{% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -407,6 +492,7 @@ function addInstallRow() {
<input type="number" id="edit-slots" name="battery_slots" value="{{ device.battery_slots }}" min="1" required> <input type="number" id="edit-slots" name="battery_slots" value="{{ device.battery_slots }}" min="1" required>
</div> </div>
{% endif %} {% endif %}
{% if not device.is_subcomponent() %}
<div class="form-group"> <div class="form-group">
<label>Type</label> <label>Type</label>
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %} {% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
@@ -427,6 +513,7 @@ function addInstallRow() {
placeholder="Enter device type" 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;"> style="display:{% if device.device_type and device.device_type not in _preset_types %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div> </div>
{% endif %}
{% if not device.has_children() %} {% if not device.has_children() %}
<div class="form-group"> <div class="form-group">
<label>Battery Size</label> <label>Battery Size</label>
@@ -452,6 +539,7 @@ function addInstallRow() {
style="display:{% if device.battery_size and device.battery_size not in _preset_sizes %}''{% else %}none{% endif %};margin-top:0.4rem;"> style="display:{% if device.battery_size and device.battery_size not in _preset_sizes %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div> </div>
{% endif %} {% endif %}
{% if not device.is_subcomponent() %}
<div class="form-group"> <div class="form-group">
<label>Location</label> <label>Location</label>
<select id="edit-location-select" onchange="editLocationSelectChanged(this)"> <select id="edit-location-select" onchange="editLocationSelectChanged(this)">
@@ -469,6 +557,7 @@ function addInstallRow() {
placeholder="e.g. Living Room, Bedroom" 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;"> style="display:{% if device.location and device.location not in device_locations|default([]) %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div> </div>
{% endif %}
<div class="form-group"> <div class="form-group">
<label for="edit-notes">Notes</label> <label for="edit-notes">Notes</label>
+8 -9
View File
@@ -57,9 +57,10 @@
</thead> </thead>
<tbody> <tbody>
{% for d in devices %} {% for d in devices %}
{% set installed = d.installed_count() %} {% set installed = d.effective_installed_count() %}
{% if installed == 0 %}{% set fill_state = 'empty' %} {% set total_slots = d.effective_slots() %}
{% elif installed >= d.battery_slots %}{% set fill_state = 'full' %} {% 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 %} {% else %}{% set fill_state = 'partial' %}{% endif %}
<tr data-type="{{ d.device_type or '' }}" <tr data-type="{{ d.device_type or '' }}"
data-battery-size="{{ d.battery_size or '' }}" data-battery-size="{{ d.battery_size or '' }}"
@@ -68,19 +69,17 @@
data-name="{{ d.name|lower }}"> data-name="{{ d.name|lower }}">
<td data-label="Device"> <td data-label="Device">
<a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a> <a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a>
{% if d.parent %} {% if d.has_children() %}
<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() %}
<br><small class="text-muted">{{ d.children|length }} sub-component{{ 's' if d.children|length != 1 }}</small> <br><small class="text-muted">{{ d.children|length }} sub-component{{ 's' if d.children|length != 1 }}</small>
{% endif %} {% endif %}
</td> </td>
<td data-label="Type">{{ d.device_type or '—' }}</td> <td data-label="Type">{{ d.device_type or '—' }}</td>
<td data-label="Size">{{ d.battery_size or '—' }}</td> <td data-label="Size">{{ d.battery_size or '—' }}</td>
<td data-label="Location">{{ d.location 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"> <td data-label="Installed">
{{ installed }} / {{ d.battery_slots }} {{ installed }} / {{ total_slots }}
{% if installed >= d.battery_slots %} {% if total_slots > 0 and installed >= total_slots %}
<span class="badge badge-retired">Full</span> <span class="badge badge-retired">Full</span>
{% endif %} {% endif %}
</td> </td>
+93
View File
@@ -975,3 +975,96 @@ def test_subcomponent_ha_entity_id(client):
resp = client.get("/device/2") resp = client.get("/device/2")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Hub" in resp.data 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