Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ed4c9420 | ||
|
|
23eeeafff7 |
@@ -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,23 +747,24 @@ 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")
|
||||
if parent_device:
|
||||
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,
|
||||
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,19 @@ def create_app(config_object="config"):
|
||||
changed = True
|
||||
if changed:
|
||||
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,
|
||||
available_batteries=available_batteries,
|
||||
device_types=device_types,
|
||||
@@ -812,7 +829,9 @@ 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,
|
||||
children_avail=children_avail)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Devices — edit
|
||||
@@ -840,13 +859,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 +892,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 +913,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 +1359,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 +1375,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()
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
|
||||
+109
-20
@@ -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>
|
||||
@@ -98,13 +112,79 @@
|
||||
<div class="card">
|
||||
{% if device.children %}
|
||||
<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 %}
|
||||
<a href="{{ url_for('device_detail', device_id=child.id) }}"
|
||||
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);">
|
||||
<strong>{{ child.name }}</strong><br>
|
||||
<small class="text-muted">{{ child.installed_count() }}/{{ child.battery_slots }} slots · {{ child.battery_size }}{% if child.ha_entity_id %} · HA{% endif %}</small>
|
||||
</a>
|
||||
{% set child_free = child.battery_slots - child.installed_count() %}
|
||||
{% set child_batteries = children_avail.get(child.id, []) %}
|
||||
<details style="border:1px solid var(--border);border-radius:6px;background:var(--bg-card);">
|
||||
<summary style="padding:0.6rem 0.9rem;cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;">
|
||||
<span>
|
||||
<strong>{{ child.name }}</strong>
|
||||
<small class="text-muted" style="margin-left:0.5rem;">{{ child.installed_count() }}/{{ child.battery_slots }} slots · {{ child.battery_size }}{% if child.ha_entity_id %} · 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 %}
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -116,24 +196,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;"> {{ 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 +227,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 +492,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 +513,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 +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;">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not device.is_subcomponent() %}
|
||||
<div class="form-group">
|
||||
<label>Location</label>
|
||||
<select id="edit-location-select" onchange="editLocationSelectChanged(this)">
|
||||
@@ -469,6 +557,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user