Add sub-component support via self-referential device hierarchy
This commit is contained in:
@@ -661,6 +661,12 @@ def create_app(config_object="config"):
|
|||||||
device_locations = sorted({d.location for d in all_devices if d.location})
|
device_locations = sorted({d.location for d in all_devices if d.location})
|
||||||
device_battery_sizes = sorted({d.battery_size for d in all_devices if d.battery_size})
|
device_battery_sizes = sorted({d.battery_size for d in all_devices if d.battery_size})
|
||||||
|
|
||||||
|
# Pre-fill parent for "Add Sub-component" links
|
||||||
|
prefill_parent_id = request.args.get("parent_id", "").strip()
|
||||||
|
prefill_parent = None
|
||||||
|
if prefill_parent_id:
|
||||||
|
prefill_parent = db.get(Device, int(prefill_parent_id)) if prefill_parent_id.isdigit() else None
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
name = request.form.get("name", "").strip()
|
name = request.form.get("name", "").strip()
|
||||||
slots_raw = request.form.get("battery_slots", "1").strip()
|
slots_raw = request.form.get("battery_slots", "1").strip()
|
||||||
@@ -669,12 +675,31 @@ def create_app(config_object="config"):
|
|||||||
battery_size = request.form.get("battery_size", "").strip() or None
|
battery_size = request.form.get("battery_size", "").strip() or None
|
||||||
location = request.form.get("location", "").strip() or None
|
location = request.form.get("location", "").strip() or None
|
||||||
|
|
||||||
|
# Resolve parent device
|
||||||
|
parent_id_raw = request.form.get("parent_id", "").strip()
|
||||||
|
parent_device = None
|
||||||
|
if parent_id_raw and parent_id_raw.isdigit():
|
||||||
|
parent_device = db.get(Device, int(parent_id_raw))
|
||||||
|
if not parent_device:
|
||||||
|
flash("Parent device not found.", "error")
|
||||||
|
return render_template("device_add.html",
|
||||||
|
device_types=device_types,
|
||||||
|
device_locations=device_locations,
|
||||||
|
device_battery_sizes=device_battery_sizes), 400
|
||||||
|
if parent_device.is_subcomponent():
|
||||||
|
flash("Cannot nest sub-components more than one level deep.", "error")
|
||||||
|
return render_template("device_add.html",
|
||||||
|
device_types=device_types,
|
||||||
|
device_locations=device_locations,
|
||||||
|
device_battery_sizes=device_battery_sizes), 400
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
flash("Device name is required.", "error")
|
flash("Device name is required.", "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,
|
||||||
device_battery_sizes=device_battery_sizes), 400
|
device_battery_sizes=device_battery_sizes,
|
||||||
|
prefill_parent=parent_device), 400
|
||||||
|
|
||||||
if not battery_size:
|
if not battery_size:
|
||||||
flash("Battery size is required.", "error")
|
flash("Battery size is required.", "error")
|
||||||
@@ -683,7 +708,8 @@ def create_app(config_object="config"):
|
|||||||
device_locations=device_locations,
|
device_locations=device_locations,
|
||||||
device_battery_sizes=device_battery_sizes,
|
device_battery_sizes=device_battery_sizes,
|
||||||
form_name=name, form_notes=notes or "",
|
form_name=name, form_notes=notes or "",
|
||||||
form_device_type=request.form.get("device_type", "")), 400
|
form_device_type=request.form.get("device_type", ""),
|
||||||
|
prefill_parent=parent_device), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
slots = int(slots_raw)
|
slots = int(slots_raw)
|
||||||
@@ -696,7 +722,8 @@ def create_app(config_object="config"):
|
|||||||
device_locations=device_locations,
|
device_locations=device_locations,
|
||||||
device_battery_sizes=device_battery_sizes,
|
device_battery_sizes=device_battery_sizes,
|
||||||
form_name=name, form_notes=notes or "",
|
form_name=name, form_notes=notes or "",
|
||||||
form_device_type=request.form.get("device_type", "")), 400
|
form_device_type=request.form.get("device_type", ""),
|
||||||
|
prefill_parent=parent_device), 400
|
||||||
|
|
||||||
if db.query(Device).filter_by(name=name).first():
|
if db.query(Device).filter_by(name=name).first():
|
||||||
flash(f"A device named '{name}' already exists.", "error")
|
flash(f"A device named '{name}' already exists.", "error")
|
||||||
@@ -706,19 +733,24 @@ def create_app(config_object="config"):
|
|||||||
device_battery_sizes=device_battery_sizes,
|
device_battery_sizes=device_battery_sizes,
|
||||||
form_name=name, form_slots=slots,
|
form_name=name, form_slots=slots,
|
||||||
form_notes=notes or "",
|
form_notes=notes or "",
|
||||||
form_device_type=request.form.get("device_type", "")), 400
|
form_device_type=request.form.get("device_type", ""),
|
||||||
|
prefill_parent=parent_device), 400
|
||||||
|
|
||||||
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=device_type, battery_size=battery_size,
|
||||||
location=location)
|
location=location,
|
||||||
|
parent_id=parent_device.id if parent_device else None)
|
||||||
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:
|
||||||
|
return redirect(url_for("device_detail", device_id=parent_device.id))
|
||||||
return redirect(url_for("device_list"))
|
return redirect(url_for("device_list"))
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — detail
|
# Devices — detail
|
||||||
@@ -767,6 +799,7 @@ def create_app(config_object="config"):
|
|||||||
device_types=device_types,
|
device_types=device_types,
|
||||||
device_locations=device_locations,
|
device_locations=device_locations,
|
||||||
device_battery_sizes=device_battery_sizes,
|
device_battery_sizes=device_battery_sizes,
|
||||||
|
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)
|
||||||
@@ -800,6 +833,23 @@ def create_app(config_object="config"):
|
|||||||
flash(f"A device named '{name}' already exists.", "error")
|
flash(f"A device named '{name}' already exists.", "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
|
||||||
|
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:
|
||||||
|
flash("Parent device not found.", "error")
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
if new_parent.id == device_id:
|
||||||
|
flash("A device cannot be its own parent.", "error")
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
if new_parent.is_subcomponent():
|
||||||
|
flash("Cannot nest sub-components more than one level deep.", "error")
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
device.parent_id = new_parent.id
|
||||||
|
else:
|
||||||
|
device.parent_id = None
|
||||||
|
|
||||||
device.name = name
|
device.name = name
|
||||||
device.battery_slots = slots
|
device.battery_slots = slots
|
||||||
device.notes = notes
|
device.notes = notes
|
||||||
@@ -939,9 +989,15 @@ def create_app(config_object="config"):
|
|||||||
battery.status = "available"
|
battery.status = "available"
|
||||||
battery.device_id = None
|
battery.device_id = None
|
||||||
name = device.name
|
name = device.name
|
||||||
|
child_names = [c.name for c in device.children]
|
||||||
|
for child in device.children:
|
||||||
|
child.parent_id = None
|
||||||
db.delete(device)
|
db.delete(device)
|
||||||
db.commit()
|
db.commit()
|
||||||
flash(f"Device '{name}' deleted. All batteries marked available.", "success")
|
msg = f"Device '{name}' deleted. All batteries marked available."
|
||||||
|
if child_names:
|
||||||
|
msg += f" Sub-components ({', '.join(child_names)}) are now independent devices."
|
||||||
|
flash(msg, "success")
|
||||||
return redirect(url_for("device_list"))
|
return redirect(url_for("device_list"))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class Device(Base):
|
|||||||
location = Column(String(100), nullable=True)
|
location = Column(String(100), nullable=True)
|
||||||
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)
|
||||||
|
|
||||||
batteries = relationship("Battery", back_populates="device")
|
batteries = relationship("Battery", back_populates="device")
|
||||||
logbook_entries = relationship(
|
logbook_entries = relationship(
|
||||||
@@ -40,6 +41,20 @@ class Device(Base):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
single_parent=True,
|
single_parent=True,
|
||||||
)
|
)
|
||||||
|
parent = relationship(
|
||||||
|
"Device",
|
||||||
|
back_populates="children",
|
||||||
|
primaryjoin="Device.parent_id == Device.id",
|
||||||
|
foreign_keys="[Device.parent_id]",
|
||||||
|
remote_side="[Device.id]",
|
||||||
|
)
|
||||||
|
children = relationship(
|
||||||
|
"Device",
|
||||||
|
back_populates="parent",
|
||||||
|
primaryjoin="Device.parent_id == Device.id",
|
||||||
|
foreign_keys="[Device.parent_id]",
|
||||||
|
order_by="Device.name",
|
||||||
|
)
|
||||||
|
|
||||||
def installed_count(self):
|
def installed_count(self):
|
||||||
return sum(1 for b in self.batteries if b.status == "installed")
|
return sum(1 for b in self.batteries if b.status == "installed")
|
||||||
@@ -50,6 +65,12 @@ class Device(Base):
|
|||||||
def has_mixed_brands(self):
|
def has_mixed_brands(self):
|
||||||
return len(self.installed_brands()) > 1
|
return len(self.installed_brands()) > 1
|
||||||
|
|
||||||
|
def has_children(self):
|
||||||
|
return bool(self.children)
|
||||||
|
|
||||||
|
def is_subcomponent(self):
|
||||||
|
return self.parent_id is not None
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Device {self.name}>"
|
return f"<Device {self.name}>"
|
||||||
|
|
||||||
|
|||||||
@@ -264,6 +264,10 @@
|
|||||||
</td>
|
</td>
|
||||||
<td data-label="Assigned To">
|
<td data-label="Assigned To">
|
||||||
{% if b.device %}
|
{% if b.device %}
|
||||||
|
{% if b.device.parent %}
|
||||||
|
<a href="{{ url_for('device_detail', device_id=b.device.parent.id) }}">{{ b.device.parent.name }}</a>
|
||||||
|
<span class="text-muted"> / </span>
|
||||||
|
{% endif %}
|
||||||
<a href="{{ url_for('device_detail', device_id=b.device.id) }}">{{ b.device.name }}</a>
|
<a href="{{ url_for('device_detail', device_id=b.device.id) }}">{{ b.device.name }}</a>
|
||||||
{% if b.device.has_mixed_brands() %}
|
{% if b.device.has_mixed_brands() %}
|
||||||
<span class="badge badge-warning" title="Mixed brands in this device">⚠ mixed</span>
|
<span class="badge badge-warning" title="Mixed brands in this device">⚠ mixed</span>
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
{% block title %}Add Device — Battery Tracker{% endblock %}
|
{% block title %}Add Device — Battery Tracker{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Add Device</h1>
|
<h1>{% if prefill_parent %}Add Sub-component to {{ prefill_parent.name }}{% else %}Add Device{% endif %}</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form method="post" action="{{ url_for('device_add') }}">
|
<form method="post" action="{{ url_for('device_add') }}">
|
||||||
|
{% if prefill_parent %}
|
||||||
|
<input type="hidden" name="parent_id" value="{{ prefill_parent.id }}">
|
||||||
|
<p class="text-muted" style="margin-bottom:0.75rem;">
|
||||||
|
Adding as a sub-component of <a href="{{ url_for('device_detail', device_id=prefill_parent.id) }}">{{ prefill_parent.name }}</a>.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Device Name <span class="text-danger">*</span></label>
|
<label for="name">Device Name <span class="text-danger">*</span></label>
|
||||||
<input type="text" id="name" name="name" value="{{ form_name|default('') }}"
|
<input type="text" id="name" name="name" value="{{ form_name|default('') }}"
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
{% block title %}{{ device.name }} — Battery Tracker{% endblock %}
|
{% block title %}{{ device.name }} — Battery Tracker{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if device.parent %}
|
||||||
|
<div style="margin-bottom:0.5rem;font-size:0.9rem;">
|
||||||
|
<a href="{{ url_for('device_detail', device_id=device.parent.id) }}">← {{ device.parent.name }}</a>
|
||||||
|
<span class="text-muted"> / {{ device.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<h1>{{ device.name }}</h1>
|
<h1>{{ device.name }}</h1>
|
||||||
|
|
||||||
<div class="card" style="position:relative;">
|
<div class="card" style="position:relative;">
|
||||||
@@ -63,6 +69,12 @@
|
|||||||
<td style="border:none;">{{ device.notes }}</td>
|
<td style="border:none;">{{ device.notes }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if device.parent %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Part of</td>
|
||||||
|
<td style="border:none;"><a href="{{ url_for('device_detail', device_id=device.parent.id) }}">{{ device.parent.name }}</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% if ha_enabled and device.ha_entity_id %}
|
{% if ha_enabled and device.ha_entity_id %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">HA Live %</td>
|
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">HA Live %</td>
|
||||||
@@ -82,6 +94,27 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if device.children or not device.is_subcomponent() %}
|
||||||
|
<div class="card">
|
||||||
|
{% if device.children %}
|
||||||
|
<h2>Sub-components</h2>
|
||||||
|
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;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>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<h2>Sub-components</h2>
|
||||||
|
<p class="text-muted" style="margin-bottom:0.75rem;">No sub-components. Batteries are tracked at the device level.</p>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-sm btn-secondary" href="{{ url_for('device_add') }}?parent_id={{ device.id }}">+ Add Sub-component</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Install Batteries</h2>
|
<h2>Install Batteries</h2>
|
||||||
{% set free_slots = device.battery_slots - device.installed_count() %}
|
{% set free_slots = device.battery_slots - device.installed_count() %}
|
||||||
@@ -401,6 +434,19 @@ function addInstallRow() {
|
|||||||
<label for="edit-notes">Notes</label>
|
<label for="edit-notes">Notes</label>
|
||||||
<textarea id="edit-notes" name="notes">{{ device.notes or '' }}</textarea>
|
<textarea id="edit-notes" name="notes">{{ device.notes or '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{% if not device.has_children() %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-parent">Part of Device (optional)</label>
|
||||||
|
<select id="edit-parent" name="parent_id">
|
||||||
|
<option value="">— none (top-level device) —</option>
|
||||||
|
{% for d in device_list_all|default([]) %}
|
||||||
|
{% if d.id != device.id and not d.is_subcomponent() %}
|
||||||
|
<option value="{{ d.id }}" {% if device.parent_id == d.id %}selected{% endif %}>{{ d.name }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if ha_enabled %}
|
{% if ha_enabled %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-ha-entity">Home Assistant Entity ID</label>
|
<label for="edit-ha-entity">Home Assistant Entity ID</label>
|
||||||
@@ -426,6 +472,9 @@ function addInstallRow() {
|
|||||||
<h2>Delete Device</h2>
|
<h2>Delete Device</h2>
|
||||||
<p style="margin-bottom:1rem;" class="text-muted">
|
<p style="margin-bottom:1rem;" class="text-muted">
|
||||||
Deleting this device will unassign all installed batteries and mark them available.
|
Deleting this device will unassign all installed batteries and mark them available.
|
||||||
|
{% if device.has_children() %}
|
||||||
|
Sub-components ({{ device.children|map(attribute='name')|join(', ') }}) will become independent devices.
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<form method="post" action="{{ url_for('device_delete', device_id=device.id) }}">
|
<form method="post" action="{{ url_for('device_delete', device_id=device.id) }}">
|
||||||
<button class="btn btn-danger" type="submit">Delete {{ device.name }}</button>
|
<button class="btn btn-danger" type="submit">Delete {{ device.name }}</button>
|
||||||
|
|||||||
@@ -66,7 +66,14 @@
|
|||||||
data-location="{{ d.location or '' }}"
|
data-location="{{ d.location or '' }}"
|
||||||
data-fill="{{ fill_state }}"
|
data-fill="{{ fill_state }}"
|
||||||
data-name="{{ d.name|lower }}">
|
data-name="{{ d.name|lower }}">
|
||||||
<td data-label="Device"><a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a></td>
|
<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() %}
|
||||||
|
<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="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>
|
||||||
|
|||||||
@@ -885,3 +885,99 @@ def test_device_charge_all_no_installed(seeded_client):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"No installed batteries" in resp.data
|
assert b"No installed batteries" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Sub-components (self-referential device hierarchy)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _setup_rc_car(client):
|
||||||
|
"""Create RC Car Set with Remote and Car sub-components and 6 AA batteries."""
|
||||||
|
client.post("/device/add", data={"name": "RC Car Set", "battery_slots": "6", "battery_size": "AA"})
|
||||||
|
# id=1 above; add sub-components with parent_id=1
|
||||||
|
client.post("/device/add", data={"name": "Remote", "battery_slots": "2",
|
||||||
|
"battery_size": "AA", "parent_id": "1"})
|
||||||
|
client.post("/device/add", data={"name": "Car", "battery_slots": "4",
|
||||||
|
"battery_size": "AA", "parent_id": "1"})
|
||||||
|
for i in range(1, 7):
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "label": f"E{i:03d}", "size": "AA"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_subcomponent(client):
|
||||||
|
_setup_rc_car(client)
|
||||||
|
# Parent detail shows sub-components
|
||||||
|
resp = client.get("/device/1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Remote" in resp.data
|
||||||
|
assert b"Car" in resp.data
|
||||||
|
# Child detail shows breadcrumb back to parent
|
||||||
|
resp = client.get("/device/2")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"RC Car Set" in resp.data
|
||||||
|
assert b"Remote" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_subcomponent_prevents_deep_nesting(client):
|
||||||
|
_setup_rc_car(client)
|
||||||
|
# id=2 is "Remote", which already has parent_id=1
|
||||||
|
resp = client.post("/device/add", data={"name": "Button", "battery_slots": "1",
|
||||||
|
"battery_size": "AA", "parent_id": "2"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert b"one level" in resp.data.lower() or b"nest" in resp.data.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_subcomponent_install_batteries(client):
|
||||||
|
_setup_rc_car(client)
|
||||||
|
# Install 2 batteries into Remote (device id=2)
|
||||||
|
resp = client.post("/device/2/install-one", data={"battery_id": "1"}, follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = client.post("/device/2/install-one", data={"battery_id": "2"}, follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Remote is now full (2/2); a third battery should be blocked
|
||||||
|
resp = client.post("/device/2/install-one", data={"battery_id": "3"}, follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"full" in resp.data.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_shows_parent_slash_child(client):
|
||||||
|
_setup_rc_car(client)
|
||||||
|
client.post("/device/2/install-one", data={"battery_id": "1"})
|
||||||
|
resp = client.get("/", follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"RC Car Set" in resp.data
|
||||||
|
assert b"Remote" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_parent_promotes_children(client):
|
||||||
|
_setup_rc_car(client)
|
||||||
|
client.post("/device/1/delete", follow_redirects=True)
|
||||||
|
# Child devices still exist
|
||||||
|
resp = client.get("/device/2")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = client.get("/device/3")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_without_parent_unchanged(client):
|
||||||
|
client.post("/device/add", data={"name": "Torch", "battery_slots": "2", "battery_size": "AA"})
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
|
resp = client.post("/device/1/install-one", data={"battery_id": "1"}, follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"1 / 2" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_subcomponent_ha_entity_id(client):
|
||||||
|
client.post("/device/add", data={"name": "Hub", "battery_slots": "1", "battery_size": "AA"})
|
||||||
|
resp = client.post(
|
||||||
|
"/device/add",
|
||||||
|
data={"name": "Sensor A", "battery_slots": "1", "battery_size": "AA",
|
||||||
|
"parent_id": "1", "ha_entity_id": "sensor.sensor_a_battery"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
# Redirects to parent detail on success
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Sensor A" in resp.data
|
||||||
|
# Child detail shows parent breadcrumb
|
||||||
|
resp = client.get("/device/2")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Hub" in resp.data
|
||||||
|
|||||||
Reference in New Issue
Block a user