Compare commits

..
2 Commits
7 changed files with 421 additions and 49 deletions
+52 -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,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
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()
+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>
+109 -20
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>
@@ -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 &middot; {{ child.battery_size }}{% if child.ha_entity_id %} &middot; 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 &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 %}
</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;">&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 +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>
+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