Compare commits

..

3 Commits

12 changed files with 1126 additions and 92 deletions
+470 -26
View File
@@ -1,11 +1,15 @@
from flask import Flask, render_template, redirect, url_for, request, flash, abort, send_from_directory, jsonify
from flask import Flask, render_template, redirect, url_for, request, flash, abort, send_from_directory, jsonify, Response
from sqlalchemy import create_engine, func
from sqlalchemy.orm import scoped_session, sessionmaker
from datetime import datetime, date, timedelta
import csv
import io
import json
import re
import zipfile
from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog
from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog, Logbook
def _parse_date(val: str) -> str | None:
@@ -231,7 +235,8 @@ def create_app(config_object="config"):
pct_logs=pct_logs,
charge_logs_data=charge_logs_data,
capacity_tests_data=capacity_tests_data,
pct_logs_data=pct_logs_data)
pct_logs_data=pct_logs_data,
logbook_entries=battery.logbook_entries)
# ------------------------------------------------------------------ #
# Battery — edit notes
@@ -634,8 +639,10 @@ def create_app(config_object="config"):
devices = db.query(Device).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})
return render_template("device_list.html", devices=devices,
device_types=device_types, device_locations=device_locations)
device_types=device_types, device_locations=device_locations,
device_battery_sizes=device_battery_sizes)
# ------------------------------------------------------------------ #
# Devices — add
@@ -646,19 +653,31 @@ def create_app(config_object="config"):
all_devices = db.query(Device).all()
device_types = sorted({d.device_type for d in all_devices if d.device_type})
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})
if request.method == "POST":
name = request.form.get("name", "").strip()
slots_raw = request.form.get("battery_slots", "1").strip()
notes = request.form.get("notes", "").strip() or None
device_type = request.form.get("device_type", "").strip() or None
battery_size = request.form.get("battery_size", "").strip() or None
location = request.form.get("location", "").strip() or None
if not name:
flash("Device name is required.", "error")
return render_template("device_add.html",
device_types=device_types,
device_locations=device_locations), 400
device_locations=device_locations,
device_battery_sizes=device_battery_sizes), 400
if not battery_size:
flash("Battery size is required.", "error")
return render_template("device_add.html",
device_types=device_types,
device_locations=device_locations,
device_battery_sizes=device_battery_sizes,
form_name=name, form_notes=notes or "",
form_device_type=request.form.get("device_type", "")), 400
try:
slots = int(slots_raw)
@@ -669,6 +688,7 @@ def create_app(config_object="config"):
return render_template("device_add.html",
device_types=device_types,
device_locations=device_locations,
device_battery_sizes=device_battery_sizes,
form_name=name, form_notes=notes or "",
form_device_type=request.form.get("device_type", "")), 400
@@ -677,19 +697,22 @@ def create_app(config_object="config"):
return render_template("device_add.html",
device_types=device_types,
device_locations=device_locations,
device_battery_sizes=device_battery_sizes,
form_name=name, form_slots=slots,
form_notes=notes or "",
form_device_type=request.form.get("device_type", "")), 400
device = Device(name=name, battery_slots=slots, notes=notes,
device_type=device_type, location=location)
device_type=device_type, battery_size=battery_size,
location=location)
db.add(device)
db.commit()
flash(f"Device '{name}' added.", "success")
return redirect(url_for("device_list"))
return render_template("device_add.html", device_types=device_types,
device_locations=device_locations)
device_locations=device_locations,
device_battery_sizes=device_battery_sizes)
# ------------------------------------------------------------------ #
# Devices — detail
@@ -700,12 +723,22 @@ def create_app(config_object="config"):
device = db.get(Device, device_id)
if device is None:
abort(404)
brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()]
available_batteries = (db.query(Battery)
.filter_by(status="available")
.order_by(Battery.label).all())
device_types = sorted({d.device_type for d in db.query(Device).all() if d.device_type})
device_locations = sorted({d.location for d in db.query(Device).all() if d.location})
all_devices = db.query(Device).all()
brands_q = db.query(Battery.brand).filter(Battery.status == "available")
if device.battery_size:
brands_q = brands_q.filter(
(Battery.size == device.battery_size) | (Battery.size == None)
)
brands = [r[0] for r in brands_q.distinct().order_by(Battery.brand).all()]
avail_q = db.query(Battery).filter_by(status="available")
if device.battery_size:
avail_q = avail_q.filter(
(Battery.size == device.battery_size) | (Battery.size == None)
)
available_batteries = avail_q.order_by(Battery.label).all()
device_types = sorted({d.device_type for d in all_devices if d.device_type})
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})
ha_live_pct = None
if ha_client.enabled and device.ha_entity_id:
ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=1)
@@ -727,8 +760,10 @@ def create_app(config_object="config"):
available_batteries=available_batteries,
device_types=device_types,
device_locations=device_locations,
device_battery_sizes=device_battery_sizes,
ha_enabled=ha_client.enabled,
ha_live_pct=ha_live_pct)
ha_live_pct=ha_live_pct,
logbook_entries=device.logbook_entries)
# ------------------------------------------------------------------ #
# Devices — edit
@@ -763,6 +798,9 @@ def create_app(config_object="config"):
device.battery_slots = slots
device.notes = notes
device.device_type = device_type
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()
@@ -807,11 +845,12 @@ def create_app(config_object="config"):
# Validate availability before writing anything
for brand, qty in pairs:
available_count = (
db.query(func.count(Battery.id))
.filter_by(brand=brand, status="available")
.scalar()
)
avail_q = db.query(func.count(Battery.id)).filter_by(brand=brand, status="available")
if device.battery_size:
avail_q = avail_q.filter(
(Battery.size == device.battery_size) | (Battery.size == None)
)
available_count = avail_q.scalar()
if available_count < qty:
flash(
f"Need {qty} {brand}, but only {available_count} available.",
@@ -822,13 +861,12 @@ def create_app(config_object="config"):
# All checks passed — perform installs
total_installed = 0
for brand, qty in pairs:
batch = (
db.query(Battery)
.filter_by(brand=brand, status="available")
.order_by(Battery.id)
.limit(qty)
.all()
)
batch_q = db.query(Battery).filter_by(brand=brand, status="available")
if device.battery_size:
batch_q = batch_q.filter(
(Battery.size == device.battery_size) | (Battery.size == None)
)
batch = batch_q.order_by(Battery.id).limit(qty).all()
for b in batch:
b.status = "installed"
b.device_id = device.id
@@ -870,6 +908,12 @@ def create_app(config_object="config"):
f"{', '.join(existing_brands)}. Mixing brands is not recommended.",
"warning",
)
if device.battery_size and battery.size and battery.size != device.battery_size:
flash(
f"Warning: {device.name} requires {device.battery_size} batteries, "
f"but {battery.label} is {battery.size}.",
"warning",
)
battery.status = "installed"
battery.device_id = device.id
db.commit()
@@ -916,6 +960,406 @@ def create_app(config_object="config"):
)
return redirect(url_for("device_list"))
# ------------------------------------------------------------------ #
# Export
# ------------------------------------------------------------------ #
def _batteries_csv():
rows = db.query(Battery).order_by(Battery.id).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "label", "brand", "status", "device_id", "device_name",
"size", "chemistry", "capacity_mah", "tested_capacity_mah",
"tested_date", "charge_cycles", "purchase_date",
"storage_location", "battery_percentage", "notes"])
for b in rows:
w.writerow([b.id, b.label, b.brand, b.status, b.device_id or "",
b.device.name if b.device else "",
b.size or "", b.chemistry or "", b.capacity_mah or "",
b.tested_capacity_mah or "", b.tested_date or "",
b.charge_cycles or "", b.purchase_date or "",
b.storage_location or "", b.battery_percentage or "", b.notes or ""])
return buf.getvalue()
def _devices_csv():
rows = db.query(Device).order_by(Device.id).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "name", "battery_slots", "installed_count",
"device_type", "battery_size", "location", "ha_entity_id", "notes"])
for d in rows:
w.writerow([d.id, d.name, d.battery_slots, d.installed_count(),
d.device_type or "", d.battery_size or "",
d.location or "", d.ha_entity_id or "", d.notes or ""])
return buf.getvalue()
def _charge_logs_csv():
rows = db.query(ChargeLog).order_by(ChargeLog.id).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "battery_id", "battery_label", "charged_date", "increment_cycles", "notes"])
for l in rows:
w.writerow([l.id, l.battery_id, l.battery.label,
l.charged_date, l.increment_cycles, l.notes or ""])
return buf.getvalue()
def _capacity_tests_csv():
rows = db.query(CapacityTest).order_by(CapacityTest.id).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "battery_id", "battery_label", "tested_capacity_mah", "tested_date", "notes"])
for t in rows:
w.writerow([t.id, t.battery_id, t.battery.label,
t.tested_capacity_mah, t.tested_date, t.notes or ""])
return buf.getvalue()
def _pct_logs_csv():
rows = db.query(BatteryPctLog).order_by(BatteryPctLog.id).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "battery_id", "battery_label", "percentage", "recorded_at", "source"])
for l in rows:
w.writerow([l.id, l.battery_id, l.battery.label,
l.percentage, l.recorded_at, l.source or ""])
return buf.getvalue()
@app.route("/export")
def export_page():
return render_template("export.html")
@app.route("/export/batteries.csv")
def export_batteries_csv():
return Response(_batteries_csv(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=batteries.csv"})
@app.route("/export/devices.csv")
def export_devices_csv():
return Response(_devices_csv(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=devices.csv"})
@app.route("/export/charge-logs.csv")
def export_charge_logs_csv():
return Response(_charge_logs_csv(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=charge-logs.csv"})
@app.route("/export/capacity-tests.csv")
def export_capacity_tests_csv():
return Response(_capacity_tests_csv(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=capacity-tests.csv"})
@app.route("/export/pct-logs.csv")
def export_pct_logs_csv():
return Response(_pct_logs_csv(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=pct-logs.csv"})
@app.route("/export/csv.zip")
def export_csv_zip():
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("batteries.csv", _batteries_csv())
zf.writestr("devices.csv", _devices_csv())
zf.writestr("charge-logs.csv", _charge_logs_csv())
zf.writestr("capacity-tests.csv", _capacity_tests_csv())
zf.writestr("pct-logs.csv", _pct_logs_csv())
zip_buf.seek(0)
fname = f"battery-tracker-{datetime.utcnow().strftime('%Y%m%d')}.zip"
return Response(zip_buf.read(), mimetype="application/zip",
headers={"Content-Disposition": f"attachment; filename={fname}"})
@app.route("/export/all.json")
def export_json():
batteries = db.query(Battery).order_by(Battery.id).all()
devices = db.query(Device).order_by(Device.id).all()
charge_logs = db.query(ChargeLog).order_by(ChargeLog.id).all()
capacity_tests = db.query(CapacityTest).order_by(CapacityTest.id).all()
pct_logs = db.query(BatteryPctLog).order_by(BatteryPctLog.id).all()
payload = {
"exported_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
"batteries": [
{"id": b.id, "label": b.label, "brand": b.brand, "status": b.status,
"device_id": b.device_id, "device_name": b.device.name if b.device else None,
"size": b.size, "chemistry": b.chemistry, "capacity_mah": b.capacity_mah,
"tested_capacity_mah": b.tested_capacity_mah, "tested_date": b.tested_date,
"charge_cycles": b.charge_cycles, "purchase_date": b.purchase_date,
"storage_location": b.storage_location,
"battery_percentage": b.battery_percentage, "notes": b.notes}
for b in batteries
],
"devices": [
{"id": d.id, "name": d.name, "battery_slots": d.battery_slots,
"installed_count": d.installed_count(), "device_type": d.device_type,
"battery_size": d.battery_size, "location": d.location,
"ha_entity_id": d.ha_entity_id, "notes": d.notes}
for d in devices
],
"charge_logs": [
{"id": l.id, "battery_id": l.battery_id, "battery_label": l.battery.label,
"charged_date": l.charged_date, "increment_cycles": l.increment_cycles,
"notes": l.notes}
for l in charge_logs
],
"capacity_tests": [
{"id": t.id, "battery_id": t.battery_id, "battery_label": t.battery.label,
"tested_capacity_mah": t.tested_capacity_mah, "tested_date": t.tested_date,
"notes": t.notes}
for t in capacity_tests
],
"pct_logs": [
{"id": l.id, "battery_id": l.battery_id, "battery_label": l.battery.label,
"percentage": l.percentage, "recorded_at": l.recorded_at, "source": l.source}
for l in pct_logs
],
}
return Response(json.dumps(payload, indent=2), mimetype="application/json",
headers={"Content-Disposition": "attachment; filename=battery-tracker-export.json"})
# ------------------------------------------------------------------ #
# Import
# ------------------------------------------------------------------ #
@app.route("/import", methods=["GET", "POST"])
def import_page():
if request.method == "GET":
return render_template("import.html", results=None)
# --- file presence ---
if "file" not in request.files or request.files["file"].filename == "":
flash("No file selected.", "error")
return render_template("import.html", results=None), 400
f = request.files["file"]
# --- size check (10 MB) ---
f.seek(0, 2)
size = f.tell()
f.seek(0)
if size > 10 * 1024 * 1024:
flash("File too large (max 10 MB).", "error")
return render_template("import.html", results=None), 400
# --- JSON parse ---
try:
data = json.load(f)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
flash(f"Invalid JSON file: {e}", "error")
return render_template("import.html", results=None), 400
# --- key validation ---
if not isinstance(data, dict) or "batteries" not in data or "devices" not in data:
flash("Invalid format: JSON must contain 'batteries' and 'devices' keys.", "error")
return render_template("import.html", results=None), 400
device_id_map = {}
battery_id_map = {}
devices_created = devices_skipped = 0
batteries_created = batteries_skipped = 0
charge_logs_appended = charge_logs_skipped = 0
capacity_tests_appended = capacity_tests_skipped = 0
pct_logs_appended = pct_logs_skipped = 0
try:
# --- devices ---
for d in data.get("devices", []):
old_id = d.get("id")
name = (d.get("name") or "").strip()
if not name:
devices_skipped += 1
continue
existing = db.query(Device).filter_by(name=name).first()
if existing:
if old_id is not None:
device_id_map[old_id] = existing.id
devices_skipped += 1
else:
new_dev = Device(
name = name,
battery_slots = d.get("battery_slots") or 1,
device_type = d.get("device_type") or None,
battery_size = d.get("battery_size") or "",
location = d.get("location") or None,
ha_entity_id = d.get("ha_entity_id") or None,
notes = d.get("notes") or None,
)
db.add(new_dev)
db.flush()
if old_id is not None:
device_id_map[old_id] = new_dev.id
devices_created += 1
# --- batteries ---
for b in data.get("batteries", []):
old_id = b.get("id")
label = (b.get("label") or "").strip()
if not label:
batteries_skipped += 1
continue
existing = db.query(Battery).filter_by(label=label).first()
if existing:
if old_id is not None:
battery_id_map[old_id] = existing.id
batteries_skipped += 1
else:
old_device_id = b.get("device_id")
new_device_id = device_id_map.get(old_device_id) if old_device_id is not None else None
source_status = b.get("status", "available")
if new_device_id is not None:
status = "installed"
elif source_status == "retired":
status = "retired"
else:
status = "available"
new_bat = Battery(
label = label,
brand = b.get("brand") or "Unknown",
status = status,
device_id = new_device_id,
size = b.get("size") or None,
chemistry = b.get("chemistry") or None,
capacity_mah = b.get("capacity_mah") or None,
tested_capacity_mah = b.get("tested_capacity_mah") or None,
tested_date = b.get("tested_date") or None,
charge_cycles = b.get("charge_cycles") or None,
purchase_date = b.get("purchase_date") or None,
storage_location = b.get("storage_location") or None,
battery_percentage = b.get("battery_percentage") or None,
notes = b.get("notes") or None,
)
db.add(new_bat)
db.flush()
if old_id is not None:
battery_id_map[old_id] = new_bat.id
batteries_created += 1
# --- charge logs ---
for cl in data.get("charge_logs", []):
old_bat_id = cl.get("battery_id")
new_bat_id = battery_id_map.get(old_bat_id)
if new_bat_id is None:
charge_logs_skipped += 1
continue
db.add(ChargeLog(
battery_id = new_bat_id,
charged_date = cl.get("charged_date") or date.today().isoformat(),
increment_cycles = cl.get("increment_cycles") or 0,
notes = cl.get("notes") or None,
))
charge_logs_appended += 1
# --- capacity tests ---
for ct in data.get("capacity_tests", []):
old_bat_id = ct.get("battery_id")
new_bat_id = battery_id_map.get(old_bat_id)
if new_bat_id is None:
capacity_tests_skipped += 1
continue
db.add(CapacityTest(
battery_id = new_bat_id,
tested_capacity_mah = ct.get("tested_capacity_mah") or 0,
tested_date = ct.get("tested_date") or date.today().isoformat(),
notes = ct.get("notes") or None,
))
capacity_tests_appended += 1
# --- pct logs ---
for pl in data.get("pct_logs", []):
old_bat_id = pl.get("battery_id")
new_bat_id = battery_id_map.get(old_bat_id)
if new_bat_id is None:
pct_logs_skipped += 1
continue
db.add(BatteryPctLog(
battery_id = new_bat_id,
percentage = pl.get("percentage") or 0,
recorded_at = pl.get("recorded_at") or datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
source = pl.get("source") or None,
))
pct_logs_appended += 1
db.commit()
except Exception as e:
db.rollback()
flash(f"Import failed: {e}", "error")
return render_template("import.html", results=None), 500
flash(
f"Import complete: {devices_created} device(s) and {batteries_created} battery/ies created.",
"success",
)
results = {
"devices_created": devices_created,
"devices_skipped": devices_skipped,
"batteries_created": batteries_created,
"batteries_skipped": batteries_skipped,
"charge_logs_appended": charge_logs_appended,
"charge_logs_skipped": charge_logs_skipped,
"capacity_tests_appended": capacity_tests_appended,
"capacity_tests_skipped": capacity_tests_skipped,
"pct_logs_appended": pct_logs_appended,
"pct_logs_skipped": pct_logs_skipped,
}
return render_template("import.html", results=results)
# ------------------------------------------------------------------ #
# Logbook
# ------------------------------------------------------------------ #
@app.route("/battery/<int:battery_id>/logbook/add", methods=["POST"])
def battery_logbook_add(battery_id):
battery = db.get(Battery, battery_id)
if battery is None:
abort(404)
body = request.form.get("body", "").strip()
if not body:
flash("Entry text is required.", "error")
else:
battery.logbook_entries.append(
Logbook(body=body, recorded_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"))
)
db.commit()
flash("Logbook entry added.", "success")
return redirect(url_for("battery_detail", battery_id=battery_id))
@app.route("/battery/<int:battery_id>/logbook/<int:entry_id>/delete", methods=["POST"])
def battery_logbook_delete(battery_id, entry_id):
battery = db.get(Battery, battery_id)
if battery is None:
abort(404)
entry = db.get(Logbook, entry_id)
if entry and entry in battery.logbook_entries:
battery.logbook_entries.remove(entry)
db.commit()
flash("Logbook entry deleted.", "success")
return redirect(url_for("battery_detail", battery_id=battery_id))
@app.route("/device/<int:device_id>/logbook/add", methods=["POST"])
def device_logbook_add(device_id):
device = db.get(Device, device_id)
if device is None:
abort(404)
body = request.form.get("body", "").strip()
if not body:
flash("Entry text is required.", "error")
else:
device.logbook_entries.append(
Logbook(body=body, recorded_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"))
)
db.commit()
flash("Logbook entry added.", "success")
return redirect(url_for("device_detail", device_id=device_id))
@app.route("/device/<int:device_id>/logbook/<int:entry_id>/delete", methods=["POST"])
def device_logbook_delete(device_id, entry_id):
device = db.get(Device, device_id)
if device is None:
abort(404)
entry = db.get(Logbook, entry_id)
if entry and entry in device.logbook_entries:
device.logbook_entries.remove(entry)
db.commit()
flash("Logbook entry deleted.", "success")
return redirect(url_for("device_detail", device_id=device_id))
return app
+44 -1
View File
@@ -1,10 +1,25 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, ForeignKey
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table
from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base()
# Association tables for logbook (defined before ORM classes so they can be
# referenced as `secondary` in relationship()). String-based FKs are resolved
# lazily when create_all() binds the metadata.
battery_logbook_table = Table(
"battery_logbook", Base.metadata,
Column("battery_id", Integer, ForeignKey("battery.id", ondelete="CASCADE"), primary_key=True),
Column("logbook_id", Integer, ForeignKey("logbook.id", ondelete="CASCADE"), primary_key=True),
)
device_logbook_table = Table(
"device_logbook", Base.metadata,
Column("device_id", Integer, ForeignKey("device.id", ondelete="CASCADE"), primary_key=True),
Column("logbook_id", Integer, ForeignKey("logbook.id", ondelete="CASCADE"), primary_key=True),
)
class Device(Base):
__tablename__ = "device"
@@ -13,11 +28,18 @@ class Device(Base):
name = Column(String(100), nullable=False, unique=True)
battery_slots = Column(Integer, nullable=False, default=1)
device_type = Column(String(50), nullable=True)
battery_size = Column(String(20), nullable=False) # AA, AAA, 9V, CR2032 …
location = Column(String(100), nullable=True)
notes = Column(Text, nullable=True)
ha_entity_id = Column(String(100), nullable=True) # e.g. "sensor.tv_remote_battery"
batteries = relationship("Battery", back_populates="device")
logbook_entries = relationship(
"Logbook", secondary=device_logbook_table,
order_by="Logbook.recorded_at.desc()",
cascade="all, delete-orphan",
single_parent=True,
)
def installed_count(self):
return sum(1 for b in self.batteries if b.status == "installed")
@@ -69,6 +91,12 @@ class Battery(Base):
order_by="BatteryPctLog.recorded_at.desc()",
cascade="all, delete-orphan",
)
logbook_entries = relationship(
"Logbook", secondary=battery_logbook_table,
order_by="Logbook.recorded_at.desc()",
cascade="all, delete-orphan",
single_parent=True,
)
def is_available(self):
return self.status == "available"
@@ -126,3 +154,18 @@ class BatteryPctLog(Base):
def __repr__(self):
return f"<BatteryPctLog {self.battery_id} {self.recorded_at} {self.percentage}%>"
class Logbook(Base):
__tablename__ = "logbook"
id = Column(Integer, primary_key=True, autoincrement=True)
body = Column(Text, nullable=False)
recorded_at = Column(String(19), nullable=False) # "YYYY-MM-DD HH:MM:SS"
# Note: SQLite has no native DATETIME type (stores dates as text).
# On MariaDB this would be a native DATETIME column — swap String(19) for
# DateTime when migrating, since SQLAlchemy's DateTime maps to DATETIME there.
# String(19) is used here to match every other timestamp column in the codebase.
def __repr__(self):
return f"<Logbook {self.recorded_at}>"
+14
View File
@@ -334,6 +334,20 @@
{% block content %}{% endblock %}
</div>
<footer style="text-align:center;padding:1.25rem 1rem 1.5rem;margin-top:1rem;
border-top:1px solid var(--border);font-size:0.8rem;">
<a href="{{ url_for('export_page') }}"
style="color:var(--text-muted);text-decoration:none;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">Export data</a>
<span style="color:var(--text-muted);margin:0 0.5rem;">·</span>
<a href="{{ url_for('import_page') }}"
style="color:var(--text-muted);text-decoration:none;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">Import data</a>
</footer>
<div id="confirm-modal" role="dialog" aria-modal="true">
<div id="confirm-modal-box">
<p id="confirm-modal-msg"></p>
+32
View File
@@ -216,6 +216,38 @@
{% endif %}
</div>
<!-- Logbook -->
<div class="card">
<h2>Logbook</h2>
{% if logbook_entries %}
<div style="display:flex;flex-direction:column;gap:0.6rem;margin-bottom:1rem;">
{% for entry in logbook_entries %}
<div style="border-left:3px solid var(--border);padding:0.35rem 0.75rem;
display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;">
<div>
<div class="text-muted" style="font-size:0.78rem;margin-bottom:0.15rem;">{{ entry.recorded_at }}</div>
<div style="white-space:pre-wrap;">{{ entry.body }}</div>
</div>
<form method="post"
action="{{ url_for('battery_logbook_delete', battery_id=battery.id, entry_id=entry.id) }}"
data-confirm="Delete this logbook entry?" data-confirm-ok="Delete">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted" style="margin-bottom:0.75rem;">No logbook entries yet.</p>
{% endif %}
<h3 style="font-size:1rem;margin:0.75rem 0 0.5rem;color:var(--text-h2);">Add Entry</h3>
<form method="post" action="{{ url_for('battery_logbook_add', battery_id=battery.id) }}">
<div class="form-group" style="margin-bottom:0.5rem;">
<textarea name="body" placeholder="Write a note…" rows="2" required style="min-height:60px;"></textarea>
</div>
<button class="btn btn-primary" type="submit">Add Entry</button>
</form>
</div>
<!-- Edit Details -->
<div class="card">
<h2>Edit Details</h2>
+21
View File
@@ -18,6 +18,27 @@
value="{{ form_slots|default(1) }}" min="1" required>
</div>
<div class="form-group">
<label for="battery-size-select">Battery Size <span class="text-danger">*</span></label>
{% set _preset_sizes = ['AA','AAA','C','D','9V','CR2032','CR2025','CR2016','18650','14500','16340','26650','LR44/AG13'] %}
{% set _cur_size = form_battery_size|default('') %}
<select id="battery-size-select" onchange="metaSelectChanged(this,'battery_size')">
<option value="">— select —</option>
{% for s in _preset_sizes %}
<option value="{{ s }}" {% if _cur_size == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
{% for s in device_battery_sizes|default([]) %}
{% if s not in _preset_sizes %}
<option value="{{ s }}" {% if _cur_size == s %}selected{% endif %}>{{ s }}</option>
{% endif %}
{% endfor %}
<option value="__new__" {% if _cur_size and _cur_size not in _preset_sizes %}selected{% endif %}>Other…</option>
</select>
<input type="text" id="battery_size" name="battery_size" value="{{ _cur_size }}"
placeholder="e.g. CR123A"
style="display:{% if _cur_size and _cur_size not in _preset_sizes %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div>
<div class="form-group">
<label>Type</label>
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
+70 -2
View File
@@ -39,6 +39,12 @@
<td style="border:none;">{{ device.device_type }}</td>
</tr>
{% endif %}
{% if device.battery_size %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Battery Size</td>
<td style="border:none;">{{ device.battery_size }}</td>
</tr>
{% endif %}
{% if device.has_mixed_brands() %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Warning</td>
@@ -79,7 +85,7 @@
<div class="card">
<h2>Install Batteries</h2>
{% set free_slots = device.battery_slots - device.installed_count() %}
<p class="text-muted" style="margin-bottom:0.75rem;">{{ free_slots }} slot(s) free</p>
<p class="text-muted" style="margin-bottom:0.75rem;">{{ free_slots }} slot(s) free{% if device.battery_size %} &mdash; showing {{ device.battery_size }} batteries only{% endif %}</p>
<form method="post" action="{{ url_for('device_install', device_id=device.id) }}">
<div id="install-grid" style="display:grid;grid-template-columns:1fr auto;gap:0.4rem;max-width:400px;align-items:start;margin-bottom:0.5rem;">
<span style="font-weight:600;font-size:0.85rem;color:#64748b;">Brand</span>
@@ -115,6 +121,14 @@ function editTypeSelectChanged(sel) {
input.style.display = 'none'; input.value = sel.value;
}
}
function editSizeSelectChanged(sel) {
var input = document.getElementById('edit-battery-size');
if (sel.value === '__new__') {
input.style.display = ''; input.value = ''; input.focus();
} else {
input.style.display = 'none'; input.value = sel.value;
}
}
function editLocationSelectChanged(sel) {
var input = document.getElementById('edit-location');
if (sel.value === '__new__') {
@@ -186,7 +200,7 @@ function addInstallRow() {
</div>
<div class="card">
<h2>Install Specific Battery</h2>
<h2>Install Specific Battery{% if device.battery_size %} <small class="text-muted" style="font-weight:normal;font-size:0.8rem;">({{ device.battery_size }} only)</small>{% endif %}</h2>
{% if available_batteries %}
<form method="post" action="{{ url_for('device_install_one', device_id=device.id) }}">
<div class="form-group">
@@ -207,6 +221,37 @@ function addInstallRow() {
{% endif %}
</div>
<div class="card">
<h2>Logbook</h2>
{% if logbook_entries %}
<div style="display:flex;flex-direction:column;gap:0.6rem;margin-bottom:1rem;">
{% for entry in logbook_entries %}
<div style="border-left:3px solid var(--border);padding:0.35rem 0.75rem;
display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;">
<div>
<div class="text-muted" style="font-size:0.78rem;margin-bottom:0.15rem;">{{ entry.recorded_at }}</div>
<div style="white-space:pre-wrap;">{{ entry.body }}</div>
</div>
<form method="post"
action="{{ url_for('device_logbook_delete', device_id=device.id, entry_id=entry.id) }}"
data-confirm="Delete this logbook entry?" data-confirm-ok="Delete">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted" style="margin-bottom:0.75rem;">No logbook entries yet.</p>
{% endif %}
<h3 style="font-size:1rem;margin:0.75rem 0 0.5rem;color:var(--text-h2);">Add Entry</h3>
<form method="post" action="{{ url_for('device_logbook_add', device_id=device.id) }}">
<div class="form-group" style="margin-bottom:0.5rem;">
<textarea name="body" placeholder="Write a note…" rows="2" required style="min-height:60px;"></textarea>
</div>
<button class="btn btn-primary" type="submit">Add Entry</button>
</form>
</div>
<div class="card">
<h2>Edit Device</h2>
<form method="post" action="{{ url_for('device_edit', device_id=device.id) }}">
@@ -238,6 +283,29 @@ 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>
<div class="form-group">
<label>Battery Size</label>
{% set _preset_sizes = ['AA','AAA','C','D','9V','CR2032','CR2025','CR2016','18650','14500','16340','26650','LR44/AG13'] %}
<select id="edit-battery-size-select" onchange="editSizeSelectChanged(this)">
<option value="">— none —</option>
{% for s in _preset_sizes %}
<option value="{{ s }}" {% if device.battery_size == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
{% for s in device_battery_sizes|default([]) %}
{% if s not in _preset_sizes %}
<option value="{{ s }}" {% if device.battery_size == s %}selected{% endif %}>{{ s }}</option>
{% endif %}
{% endfor %}
{% if device.battery_size and device.battery_size not in _preset_sizes and device.battery_size not in device_battery_sizes|default([]) %}
<option value="{{ device.battery_size }}" selected>{{ device.battery_size }}</option>
{% endif %}
<option value="__new__">Other…</option>
</select>
<input type="text" id="edit-battery-size" name="battery_size"
value="{{ device.battery_size or '' }}"
placeholder="e.g. CR123A"
style="display:{% if device.battery_size and device.battery_size not in _preset_sizes %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div>
<div class="form-group">
<label>Location</label>
<select id="edit-location-select" onchange="editLocationSelectChanged(this)">
+33 -18
View File
@@ -20,6 +20,13 @@
<option value="{{ loc }}">{{ loc }}</option>
{% endfor %}
</select>
<select id="filter-battery-size" onchange="applyDeviceFilters()"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">Any Size</option>
{% for sz in device_battery_sizes|default([]) %}
<option value="{{ sz }}">{{ sz }}</option>
{% endfor %}
</select>
<select id="filter-fill" onchange="applyDeviceFilters()"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">Any Fill</option>
@@ -40,6 +47,7 @@
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Location</th>
<th>Slots</th>
<th>Installed</th>
@@ -54,11 +62,13 @@
{% elif installed >= d.battery_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 '' }}"
data-location="{{ d.location or '' }}"
data-fill="{{ fill_state }}"
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="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="Installed">
@@ -95,7 +105,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-muted" style="text-align:center;padding:1rem;">No devices yet. <a href="{{ url_for('device_add') }}">Add one.</a></td></tr>
<tr><td colspan="8" class="text-muted" style="text-align:center;padding:1rem;">No devices yet. <a href="{{ url_for('device_add') }}">Add one.</a></td></tr>
{% endfor %}
</tbody>
</table>
@@ -106,27 +116,31 @@
<script>
function applyDeviceFilters() {
var typeVal = document.getElementById('filter-type').value;
var locationVal = document.getElementById('filter-location').value;
var fillVal = document.getElementById('filter-fill').value;
var textVal = document.getElementById('filter-device-text').value.toLowerCase();
var rows = document.querySelectorAll('tbody tr[data-name]');
var visible = 0;
var typeVal = document.getElementById('filter-type').value;
var batterySizeVal = document.getElementById('filter-battery-size').value;
var locationVal = document.getElementById('filter-location').value;
var fillVal = document.getElementById('filter-fill').value;
var textVal = document.getElementById('filter-device-text').value.toLowerCase();
var rows = document.querySelectorAll('tbody tr[data-name]');
var visible = 0;
rows.forEach(function(row) {
var rowType = row.dataset.type || '';
var rowLocation = row.dataset.location || '';
var rowFill = row.dataset.fill || '';
var rowName = row.dataset.name || '';
var show = (!typeVal || rowType === typeVal) &&
(!locationVal || rowLocation === locationVal) &&
(!fillVal || rowFill === fillVal) &&
(!textVal || rowName.includes(textVal) ||
rowType.toLowerCase().includes(textVal) ||
rowLocation.toLowerCase().includes(textVal));
var rowType = row.dataset.type || '';
var rowBatterySize = row.dataset.batterySize || '';
var rowLocation = row.dataset.location || '';
var rowFill = row.dataset.fill || '';
var rowName = row.dataset.name || '';
var show = (!typeVal || rowType === typeVal) &&
(!batterySizeVal || rowBatterySize === batterySizeVal) &&
(!locationVal || rowLocation === locationVal) &&
(!fillVal || rowFill === fillVal) &&
(!textVal || rowName.includes(textVal) ||
rowType.toLowerCase().includes(textVal) ||
rowBatterySize.toLowerCase().includes(textVal) ||
rowLocation.toLowerCase().includes(textVal));
row.style.display = show ? '' : 'none';
if (show) visible++;
});
var active = typeVal || locationVal || fillVal || textVal;
var active = typeVal || batterySizeVal || locationVal || fillVal || textVal;
document.getElementById('device-filter-reset').style.display = active ? '' : 'none';
document.getElementById('device-filter-count').textContent =
active ? (visible + ' of ' + rows.length + ' shown') : '';
@@ -134,6 +148,7 @@ function applyDeviceFilters() {
function resetDeviceFilters() {
document.getElementById('filter-type').value = '';
document.getElementById('filter-battery-size').value = '';
document.getElementById('filter-location').value = '';
document.getElementById('filter-fill').value = '';
document.getElementById('filter-device-text').value = '';
+70
View File
@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}Export — Battery Tracker{% endblock %}
{% block content %}
<h1 style="margin-bottom:1.25rem;">Export Data</h1>
<div class="card" style="margin-bottom:1rem;">
<h2 style="margin-bottom:0.5rem;">Full Export</h2>
<p style="color:var(--text-muted,#6b7280);margin-bottom:1rem;">
Download your complete dataset — batteries, devices, charge logs, capacity tests, and percentage history.
</p>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
<a href="{{ url_for('export_csv_zip') }}" class="btn btn-primary">Download All CSVs (.zip)</a>
<a href="{{ url_for('export_json') }}" class="btn btn-secondary">Download JSON</a>
</div>
</div>
<div class="card">
<h2 style="margin-bottom:0.5rem;">Individual CSV Files</h2>
<p style="color:var(--text-muted,#6b7280);margin-bottom:1rem;">Download a single table at a time.</p>
<table style="border-collapse:collapse;width:100%;">
<tbody>
<tr>
<td style="padding:0.4rem 0.75rem 0.4rem 0;">
<a href="{{ url_for('export_batteries_csv') }}" class="btn btn-sm btn-secondary">batteries.csv</a>
</td>
<td style="padding:0.4rem 0;color:var(--text-muted,#6b7280);font-size:0.9rem;">
All batteries with device name
</td>
</tr>
<tr>
<td style="padding:0.4rem 0.75rem 0.4rem 0;">
<a href="{{ url_for('export_devices_csv') }}" class="btn btn-sm btn-secondary">devices.csv</a>
</td>
<td style="padding:0.4rem 0;color:var(--text-muted,#6b7280);font-size:0.9rem;">
All devices with installed battery count
</td>
</tr>
<tr>
<td style="padding:0.4rem 0.75rem 0.4rem 0;">
<a href="{{ url_for('export_charge_logs_csv') }}" class="btn btn-sm btn-secondary">charge-logs.csv</a>
</td>
<td style="padding:0.4rem 0;color:var(--text-muted,#6b7280);font-size:0.9rem;">
All charge log entries with battery label
</td>
</tr>
<tr>
<td style="padding:0.4rem 0.75rem 0.4rem 0;">
<a href="{{ url_for('export_capacity_tests_csv') }}" class="btn btn-sm btn-secondary">capacity-tests.csv</a>
</td>
<td style="padding:0.4rem 0;color:var(--text-muted,#6b7280);font-size:0.9rem;">
All capacity test results with battery label
</td>
</tr>
<tr>
<td style="padding:0.4rem 0.75rem 0.4rem 0;">
<a href="{{ url_for('export_pct_logs_csv') }}" class="btn btn-sm btn-secondary">pct-logs.csv</a>
</td>
<td style="padding:0.4rem 0;color:var(--text-muted,#6b7280);font-size:0.9rem;">
Battery percentage history
</td>
</tr>
</tbody>
</table>
</div>
<div style="margin-top:0.75rem;">
<a href="{{ url_for('import_page') }}" class="btn btn-secondary">Import JSON</a>
</div>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Import — Battery Tracker{% endblock %}
{% block content %}
<h1 style="margin-bottom:1.25rem;">Import Data</h1>
<div class="card" style="margin-bottom:1rem;">
<p style="color:var(--text-muted,#6b7280);margin-bottom:1rem;">
Upload a JSON file exported from Battery Tracker.
Devices are matched by name and batteries by label — existing records are skipped (not overwritten).
Charge logs, capacity tests, and percentage logs are always appended.
</p>
<form method="POST" action="{{ url_for('import_page') }}" enctype="multipart/form-data">
<div class="form-group">
<label for="file">JSON Export File</label>
<input type="file" id="file" name="file" accept=".json,application/json" required
style="width:100%;padding:0.45rem 0.65rem;border:1px solid var(--border-input,#d1d5db);
border-radius:4px;font-size:0.9rem;background:var(--bg-input,#fff);
color:var(--text-body,#111827);">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Import</button>
<a href="{{ url_for('export_page') }}" class="btn btn-secondary">Back to Export</a>
</div>
</form>
</div>
{% if results is not none %}
<div class="card">
<h2 style="margin-bottom:1rem;">Import Results</h2>
<table style="border-collapse:collapse;width:100%;max-width:480px;">
<thead>
<tr style="border-bottom:2px solid var(--border,#e5e7eb);">
<th style="text-align:left;padding:0.4rem 0.75rem 0.4rem 0;font-size:0.85rem;color:var(--text-muted,#6b7280);font-weight:600;">Category</th>
<th style="text-align:right;padding:0.4rem 0.75rem;font-size:0.85rem;color:var(--text-muted,#6b7280);font-weight:600;">Created / Appended</th>
<th style="text-align:right;padding:0.4rem 0;font-size:0.85rem;color:var(--text-muted,#6b7280);font-weight:600;">Skipped</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid var(--border,#e5e7eb);">
<td style="padding:0.45rem 0.75rem 0.45rem 0;">Devices</td>
<td style="text-align:right;padding:0.45rem 0.75rem;">{{ results.devices_created }}</td>
<td style="text-align:right;padding:0.45rem 0;">{{ results.devices_skipped }}</td>
</tr>
<tr style="border-bottom:1px solid var(--border,#e5e7eb);">
<td style="padding:0.45rem 0.75rem 0.45rem 0;">Batteries</td>
<td style="text-align:right;padding:0.45rem 0.75rem;">{{ results.batteries_created }}</td>
<td style="text-align:right;padding:0.45rem 0;">{{ results.batteries_skipped }}</td>
</tr>
<tr style="border-bottom:1px solid var(--border,#e5e7eb);">
<td style="padding:0.45rem 0.75rem 0.45rem 0;">Charge Logs</td>
<td style="text-align:right;padding:0.45rem 0.75rem;">{{ results.charge_logs_appended }}</td>
<td style="text-align:right;padding:0.45rem 0;">{{ results.charge_logs_skipped }}</td>
</tr>
<tr style="border-bottom:1px solid var(--border,#e5e7eb);">
<td style="padding:0.45rem 0.75rem 0.45rem 0;">Capacity Tests</td>
<td style="text-align:right;padding:0.45rem 0.75rem;">{{ results.capacity_tests_appended }}</td>
<td style="text-align:right;padding:0.45rem 0;">{{ results.capacity_tests_skipped }}</td>
</tr>
<tr>
<td style="padding:0.45rem 0.75rem 0.45rem 0;">% Logs</td>
<td style="text-align:right;padding:0.45rem 0.75rem;">{{ results.pct_logs_appended }}</td>
<td style="text-align:right;padding:0.45rem 0;">{{ results.pct_logs_skipped }}</td>
</tr>
</tbody>
</table>
<div style="margin-top:1.25rem;display:flex;gap:0.75rem;flex-wrap:wrap;">
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
<a href="{{ url_for('import_page') }}" class="btn btn-secondary">Import Another File</a>
</div>
</div>
{% endif %}
{% endblock %}
+2 -2
View File
@@ -42,8 +42,8 @@ def seeded_client(app):
id=3 BrandX 002 (BrandX, retired)
"""
with app.test_client() as c:
c.post("/device/add", data={"name": "Device A", "battery_slots": "2"})
c.post("/device/add", data={"name": "Device B", "battery_slots": "1"})
c.post("/device/add", data={"name": "Device A", "battery_slots": "2", "battery_size": "AA"})
c.post("/device/add", data={"name": "Device B", "battery_slots": "1", "battery_size": "AA"})
c.post("/battery/add", data={"brand": "BrandX", "count": "1"}) # id=1
c.post("/battery/add", data={"brand": "BrandY", "count": "1"}) # id=2
c.post("/battery/add", data={"brand": "BrandX", "count": "1"}) # id=3
+275 -21
View File
@@ -235,7 +235,7 @@ def test_bulk_delete(client):
def test_bulk_unassign(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "2"})
client.post("/device/add", data={"name": "Gadget", "battery_slots": "2", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "TestBrand", "count": "2"})
client.post("/device/1/install", data={"brand[]": "TestBrand", "qty[]": "2"})
resp = client.post("/battery/bulk-action",
@@ -269,7 +269,7 @@ def test_bulk_action_no_selection(client):
# ------------------------------------------------------------------ #
def test_device_install_autoselects(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "2"})
client.post("/device/add", data={"name": "Gadget", "battery_slots": "2", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "3"})
resp = client.post("/device/1/install",
data={"brand[]": "Eneloop", "qty[]": "2"},
@@ -281,7 +281,7 @@ def test_device_install_autoselects(client):
def test_device_install_mixed_brands(client):
client.post("/device/add", data={"name": "Remote", "battery_slots": "4"})
client.post("/device/add", data={"name": "Remote", "battery_slots": "4", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
client.post("/battery/add", data={"brand": "Energizer", "count": "2"})
resp = client.post("/device/1/install",
@@ -293,7 +293,7 @@ def test_device_install_mixed_brands(client):
def test_device_install_insufficient_batteries(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "4"})
client.post("/device/add", data={"name": "Gadget", "battery_slots": "4", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
resp = client.post("/device/1/install",
data={"brand[]": "Eneloop", "qty[]": "2"},
@@ -303,7 +303,7 @@ def test_device_install_insufficient_batteries(client):
def test_device_install_over_capacity(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "3"})
resp = client.post("/device/1/install",
data={"brand[]": "Eneloop", "qty[]": "3"},
@@ -314,7 +314,7 @@ def test_device_install_over_capacity(client):
def test_bulk_install_device(client):
"""Select multiple available batteries and install them into a device."""
client.post("/device/add", data={"name": "Box", "battery_slots": "3"})
client.post("/device/add", data={"name": "Box", "battery_slots": "3", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
resp = client.post("/battery/bulk-action",
data={"battery_ids": ["1", "2"], "action": "install_device",
@@ -326,7 +326,7 @@ def test_bulk_install_device(client):
def test_bulk_install_device_over_capacity(client):
"""Bulk install is blocked when device lacks free slots."""
client.post("/device/add", data={"name": "Box", "battery_slots": "1"})
client.post("/device/add", data={"name": "Box", "battery_slots": "1", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
resp = client.post("/battery/bulk-action",
data={"battery_ids": ["1", "2"], "action": "install_device",
@@ -338,8 +338,8 @@ def test_bulk_install_device_over_capacity(client):
def test_bulk_install_device_moves_installed_battery(client):
"""Bulk install moves a battery already installed in another device."""
client.post("/device/add", data={"name": "Box A", "battery_slots": "2"})
client.post("/device/add", data={"name": "Box B", "battery_slots": "2"})
client.post("/device/add", data={"name": "Box A", "battery_slots": "2", "battery_size": "AA"})
client.post("/device/add", data={"name": "Box B", "battery_slots": "2", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
client.post("/battery/1/assign", data={"device_id": "1"})
resp = client.post("/battery/bulk-action",
@@ -357,7 +357,7 @@ def test_bulk_install_device_moves_installed_battery(client):
def test_dashboard_quick_assign(client):
"""Quick-assign from dashboard (battery_assign POST) succeeds."""
client.post("/device/add", data={"name": "Box", "battery_slots": "2"})
client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
resp = client.post("/battery/1/assign", data={"device_id": "1"},
follow_redirects=True)
@@ -367,7 +367,7 @@ def test_dashboard_quick_assign(client):
def test_dashboard_quick_assign_full_device_blocked(client):
"""Quick-assign from dashboard to a full device is blocked."""
client.post("/device/add", data={"name": "Box", "battery_slots": "1"})
client.post("/device/add", data={"name": "Box", "battery_slots": "1", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
client.post("/battery/1/assign", data={"device_id": "1"}) # fills the slot
resp = client.post("/battery/2/assign", data={"device_id": "1"},
@@ -382,7 +382,7 @@ def test_dashboard_quick_assign_full_device_blocked(client):
def test_install_one_specific_battery(client):
"""device_install_one installs a chosen battery."""
client.post("/device/add", data={"name": "Box", "battery_slots": "2"})
client.post("/device/add", data={"name": "Box", "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)
@@ -392,7 +392,7 @@ def test_install_one_specific_battery(client):
def test_install_one_over_capacity_blocked(client):
"""device_install_one to a full device is blocked."""
client.post("/device/add", data={"name": "Box", "battery_slots": "1"})
client.post("/device/add", data={"name": "Box", "battery_slots": "1", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
client.post("/device/1/install-one", data={"battery_id": "1"}) # fills slot
resp = client.post("/device/1/install-one", data={"battery_id": "2"},
@@ -418,7 +418,7 @@ def test_device_list(seeded_client):
def test_add_device(client):
resp = client.post("/device/add",
data={"name": "My Gadget", "battery_slots": "3"},
data={"name": "My Gadget", "battery_slots": "3", "battery_size": "AA"},
follow_redirects=True)
assert resp.status_code == 200
assert b"My Gadget" in resp.data
@@ -426,7 +426,7 @@ def test_add_device(client):
def test_add_device_duplicate_name(seeded_client):
resp = seeded_client.post("/device/add",
data={"name": "Device A", "battery_slots": "2"})
data={"name": "Device A", "battery_slots": "2", "battery_size": "AA"})
assert resp.status_code == 400
assert b"already exists" in resp.data
@@ -476,21 +476,22 @@ def test_delete_device_removed(seeded_client):
def test_add_device_with_type(client):
resp = client.post("/device/add",
data={"name": "TV Remote", "battery_slots": "2", "device_type": "Remote Control"},
data={"name": "TV Remote", "battery_slots": "2", "battery_size": "AA",
"device_type": "Remote Control"},
follow_redirects=True)
assert resp.status_code == 200
assert b"TV Remote" in resp.data
def test_device_detail_shows_type(client):
client.post("/device/add", data={"name": "Torch", "battery_slots": "1", "device_type": "Flashlight"})
client.post("/device/add", data={"name": "Torch", "battery_slots": "1", "battery_size": "AA", "device_type": "Flashlight"})
resp = client.get("/device/1")
assert resp.status_code == 200
assert b"Flashlight" in resp.data
def test_edit_device_type(client):
client.post("/device/add", data={"name": "Torch", "battery_slots": "1"})
client.post("/device/add", data={"name": "Torch", "battery_slots": "1", "battery_size": "AA"})
resp = client.post("/device/1/edit",
data={"name": "Torch", "battery_slots": "1", "device_type": "Flashlight"},
follow_redirects=True)
@@ -500,7 +501,8 @@ def test_edit_device_type(client):
def test_add_device_with_location(client):
resp = client.post("/device/add",
data={"name": "Bedroom Remote", "battery_slots": "2", "location": "Bedroom"},
data={"name": "Bedroom Remote", "battery_slots": "2", "battery_size": "AA",
"location": "Bedroom"},
follow_redirects=True)
assert resp.status_code == 200
assert b"Bedroom Remote" in resp.data
@@ -509,7 +511,7 @@ def test_add_device_with_location(client):
def test_edit_device_location(client):
client.post("/device/add", data={"name": "Living Room Clock", "battery_slots": "1"})
client.post("/device/add", data={"name": "Living Room Clock", "battery_slots": "1", "battery_size": "AA"})
resp = client.post("/device/1/edit",
data={"name": "Living Room Clock", "battery_slots": "1", "location": "Living Room"},
follow_redirects=True)
@@ -545,7 +547,7 @@ def test_delete_capacity_test(client):
def test_add_install_delete_battery(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "AcmeBrand", "count": "1"})
resp = client.post("/device/1/install",
data={"brand[]": "AcmeBrand", "qty[]": "1"},
@@ -553,3 +555,255 @@ def test_add_install_delete_battery(client):
assert b"Gadget" in resp.data
client.post("/battery/1/delete")
assert client.get("/battery/1").status_code == 404
# ------------------------------------------------------------------ #
# Device — battery_size
# ------------------------------------------------------------------ #
def test_add_device_requires_battery_size(client):
"""POST without battery_size returns 400."""
resp = client.post("/device/add", data={"name": "No Size Device", "battery_slots": "2"})
assert resp.status_code == 400
assert b"Battery size is required" in resp.data
def test_add_device_with_battery_size(client):
"""Device with battery_size shows it on detail page."""
resp = client.post("/device/add",
data={"name": "AA Remote", "battery_slots": "2", "battery_size": "AA"},
follow_redirects=True)
assert resp.status_code == 200
resp2 = client.get("/device/1")
assert b"AA" in resp2.data
def test_install_one_filters_by_size(client):
"""Single-battery install dropdown excludes batteries with wrong size."""
client.post("/device/add", data={"name": "AA Device", "battery_slots": "2", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"}) # id=1
client.post("/battery/1/edit-details", data={"size": "AA"})
client.post("/battery/add", data={"brand": "Energizer", "count": "1"}) # id=2
client.post("/battery/2/edit-details", data={"size": "AAA"})
resp = client.get("/device/1")
assert resp.status_code == 200
# AA battery should appear in the dropdown, AAA should not
assert b"Eneloop 001" in resp.data
assert b"Energizer 001" not in resp.data
def test_bulk_install_filters_by_size(client):
"""Bulk install with a brand that only has wrong-size batteries fails with 0 available."""
client.post("/device/add", data={"name": "AA Device", "battery_slots": "2", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Energizer", "count": "2"})
# Set both batteries to AAA (explicitly incompatible)
client.post("/battery/1/edit-details", data={"size": "AAA"})
client.post("/battery/2/edit-details", data={"size": "AAA"})
resp = client.post("/device/1/install",
data={"brand[]": "Energizer", "qty[]": "1"},
follow_redirects=True)
assert resp.status_code == 200
# ------------------------------------------------------------------ #
# Import
# ------------------------------------------------------------------ #
import io
import json as _json
def _make_import_payload(devices=None, batteries=None,
charge_logs=None, capacity_tests=None, pct_logs=None):
data = {
"exported_at": "2026-01-01T00:00:00",
"devices": devices or [],
"batteries": batteries or [],
"charge_logs": charge_logs or [],
"capacity_tests": capacity_tests or [],
"pct_logs": pct_logs or [],
}
return (io.BytesIO(_json.dumps(data).encode()), "export.json")
def _post_import(client, payload_tuple):
buf, fname = payload_tuple
return client.post(
"/import",
data={"file": (buf, fname)},
content_type="multipart/form-data",
)
def _bat(id, label, brand="Brand", status="available", device_id=None, **kw):
return {"id": id, "label": label, "brand": brand, "status": status,
"device_id": device_id, "size": None, "chemistry": None, "capacity_mah": None,
"tested_capacity_mah": None, "tested_date": None, "charge_cycles": None,
"purchase_date": None, "storage_location": None, "battery_percentage": None,
"notes": None, **kw}
def _dev(id, name, battery_slots=2, battery_size="AA", **kw):
return {"id": id, "name": name, "battery_slots": battery_slots,
"battery_size": battery_size, "device_type": None,
"location": None, "ha_entity_id": None, "notes": None, **kw}
def test_import_page_loads(client):
resp = client.get("/import")
assert resp.status_code == 200
assert b"Import" in resp.data
assert b'enctype="multipart/form-data"' in resp.data
def test_import_no_file_returns_400(client):
resp = client.post("/import", data={}, content_type="multipart/form-data")
assert resp.status_code == 400
def test_import_invalid_json_returns_400(client):
buf = io.BytesIO(b"this is not json")
resp = client.post("/import",
data={"file": (buf, "bad.json")},
content_type="multipart/form-data")
assert resp.status_code == 400
assert b"Invalid JSON" in resp.data
def test_import_missing_required_keys_returns_400(client):
buf = io.BytesIO(_json.dumps({"exported_at": "2026-01-01"}).encode())
resp = client.post("/import",
data={"file": (buf, "bad.json")},
content_type="multipart/form-data")
assert resp.status_code == 400
def test_import_creates_devices_and_batteries(client):
payload = _make_import_payload(
devices=[_dev(1, "RC Car")],
batteries=[_bat(1, "Eneloop 001", brand="Eneloop")],
)
resp = _post_import(client, payload)
assert resp.status_code == 200
assert b"Import Results" in resp.data
dash = client.get("/")
assert b"Eneloop 001" in dash.data
assert b"RC Car" in dash.data
def test_import_results_show_correct_counts(client):
payload = _make_import_payload(
devices=[_dev(10, "DevX")],
batteries=[_bat(10, "BrandX 001"), _bat(11, "BrandX 002")],
)
resp = _post_import(client, payload)
assert resp.status_code == 200
# devices_created=1, batteries_created=2 appear in the results table
assert b"Import Results" in resp.data
def test_import_charge_logs_remapped_correctly(client):
payload = _make_import_payload(
batteries=[_bat(99, "Eneloop 001", brand="Eneloop")],
charge_logs=[{"id": 1, "battery_id": 99, "battery_label": "Eneloop 001",
"charged_date": "2026-01-15", "increment_cycles": 1, "notes": None}],
)
resp = _post_import(client, payload)
assert resp.status_code == 200
detail = client.get("/battery/1")
assert b"2026-01-15" in detail.data
def test_import_capacity_tests_remapped(client):
payload = _make_import_payload(
batteries=[_bat(5, "Test 001", capacity_mah=2000)],
capacity_tests=[{"id": 1, "battery_id": 5, "battery_label": "Test 001",
"tested_capacity_mah": 1850, "tested_date": "2026-02-01", "notes": None}],
)
resp = _post_import(client, payload)
assert resp.status_code == 200
detail = client.get("/battery/1")
assert b"1850" in detail.data
def test_import_skips_existing_device(client):
client.post("/device/add", data={"name": "RC Car", "battery_slots": "2", "battery_size": "AA"})
payload = _make_import_payload(
devices=[_dev(1, "RC Car", battery_slots=4)],
)
resp = _post_import(client, payload)
assert resp.status_code == 200
# Original 2-slot device not overwritten
detail = client.get("/device/1")
assert b"RC Car" in detail.data
def test_import_skips_existing_battery(client):
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
payload = _make_import_payload(
batteries=[_bat(999, "Eneloop 001", brand="Eneloop", status="retired")],
)
resp = _post_import(client, payload)
assert resp.status_code == 200
detail = client.get("/battery/1")
assert b"available" in detail.data.lower()
def test_import_log_skipped_when_battery_not_in_map(client):
payload = _make_import_payload(
batteries=[],
charge_logs=[{"id": 1, "battery_id": 42, "battery_label": "Ghost",
"charged_date": "2026-01-01", "increment_cycles": 1, "notes": None}],
)
resp = _post_import(client, payload)
assert resp.status_code == 200
assert b"Import Results" in resp.data
def test_import_battery_gets_installed_when_device_maps(client):
payload = _make_import_payload(
devices=[_dev(7, "My Device")],
batteries=[_bat(7, "Linked 001", status="installed", device_id=7)],
)
resp = _post_import(client, payload)
assert resp.status_code == 200
detail = client.get("/battery/1")
assert b"installed" in detail.data.lower()
assert b"My Device" in detail.data
def test_import_battery_retired_preserved_when_no_device(client):
payload = _make_import_payload(
batteries=[_bat(3, "Old 001", status="retired")],
)
resp = _post_import(client, payload)
assert resp.status_code == 200
detail = client.get("/battery/1")
assert b"retired" in detail.data.lower()
def test_import_battery_device_id_unknown_becomes_available(client):
payload = _make_import_payload(
devices=[],
batteries=[_bat(5, "Orphan 001", status="installed", device_id=99)],
)
resp = _post_import(client, payload)
assert resp.status_code == 200
detail = client.get("/battery/1")
assert b"available" in detail.data.lower()
def test_full_roundtrip_export_import(client):
client.post("/device/add", data={"name": "Router", "battery_slots": "1", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
client.post("/battery/1/assign", data={"device_id": "1"})
export_resp = client.get("/export/all.json")
assert export_resp.status_code == 200
buf = io.BytesIO(export_resp.data)
resp = client.post("/import",
data={"file": (buf, "export.json")},
content_type="multipart/form-data")
assert resp.status_code == 200
assert b"Import Results" in resp.data
+22 -22
View File
@@ -62,7 +62,7 @@ def test_ha_disabled_dashboard_no_ha_column(client):
def test_ha_disabled_device_no_ha_field(client):
client.post("/device/add", data={"name": "Dev A", "battery_slots": "1"})
client.post("/device/add", data={"name": "Dev A", "battery_slots": "1", "battery_size": "AA"})
resp = client.get("/device/1")
assert resp.status_code == 200
assert b"ha_entity_id" not in resp.data
@@ -153,13 +153,13 @@ def _make_session_factory(ha_app):
def test_poll_updates_installed_batteries(ha_app, ha_client_f):
"""Installed battery in a device with ha_entity_id gets battery_percentage updated."""
ha_client_f.post("/device/add", data={"name": "Dev A", "battery_slots": "2"})
ha_client_f.post("/device/add", data={"name": "Dev A", "battery_slots": "2", "battery_size": "AA"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
# Assign battery to device
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
# Set ha_entity_id on device
ha_client_f.post("/device/1/edit", data={
"name": "Dev A", "battery_slots": "2", "ha_entity_id": "sensor.dev_a_battery"
"name": "Dev A", "battery_slots": "2", "battery_size": "AA", "ha_entity_id": "sensor.dev_a_battery"
})
from ha_client import HomeAssistantClient
@@ -184,11 +184,11 @@ def test_poll_updates_installed_batteries(ha_app, ha_client_f):
def test_poll_skips_uninstalled_batteries(ha_app, ha_client_f):
"""Batteries that are available (not installed) are not updated by the poller."""
ha_client_f.post("/device/add", data={"name": "Dev B", "battery_slots": "1"})
ha_client_f.post("/device/add", data={"name": "Dev B", "battery_slots": "1", "battery_size": "AA"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
# Set entity on device but do NOT install the battery
ha_client_f.post("/device/1/edit", data={
"name": "Dev B", "battery_slots": "1", "ha_entity_id": "sensor.dev_b_battery"
"name": "Dev B", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.dev_b_battery"
})
from ha_client import HomeAssistantClient
@@ -211,7 +211,7 @@ def test_poll_skips_uninstalled_batteries(ha_app, ha_client_f):
def test_poll_skips_devices_without_entity_id(ha_app, ha_client_f):
"""Devices with no ha_entity_id must never reach get_state."""
ha_client_f.post("/device/add", data={"name": "Dev C", "battery_slots": "1"})
ha_client_f.post("/device/add", data={"name": "Dev C", "battery_slots": "1", "battery_size": "AA"})
from ha_client import HomeAssistantClient
from ha_poller import HaPoller
@@ -228,11 +228,11 @@ def test_poll_skips_devices_without_entity_id(ha_app, ha_client_f):
def test_poll_handles_api_error_gracefully(ha_app, ha_client_f):
"""When get_state returns None, no exception is raised and percentage stays None."""
ha_client_f.post("/device/add", data={"name": "Dev D", "battery_slots": "1"})
ha_client_f.post("/device/add", data={"name": "Dev D", "battery_slots": "1", "battery_size": "AA"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev D", "battery_slots": "1", "ha_entity_id": "sensor.dev_d"
"name": "Dev D", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.dev_d"
})
from ha_client import HomeAssistantClient
@@ -343,29 +343,29 @@ def test_retired_battery_excluded_from_needs_attention_section(ha_app, ha_client
def test_device_detail_shows_ha_field(ha_client_f):
ha_client_f.post("/device/add", data={"name": "Dev E", "battery_slots": "1"})
ha_client_f.post("/device/add", data={"name": "Dev E", "battery_slots": "1", "battery_size": "AA"})
resp = ha_client_f.get("/device/1")
assert resp.status_code == 200
assert b"ha_entity_id" in resp.data
def test_edit_device_ha_entity_id_saves(ha_client_f):
ha_client_f.post("/device/add", data={"name": "Dev F", "battery_slots": "1"})
ha_client_f.post("/device/add", data={"name": "Dev F", "battery_slots": "1", "battery_size": "AA"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev F", "battery_slots": "1", "ha_entity_id": "sensor.my_remote"
"name": "Dev F", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.my_remote"
})
resp = ha_client_f.get("/device/1")
assert b"sensor.my_remote" in resp.data
def test_edit_device_ha_entity_id_clear(ha_client_f):
ha_client_f.post("/device/add", data={"name": "Dev G", "battery_slots": "1"})
ha_client_f.post("/device/add", data={"name": "Dev G", "battery_slots": "1", "battery_size": "AA"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev G", "battery_slots": "1", "ha_entity_id": "sensor.foo"
"name": "Dev G", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.foo"
})
# Now clear it
ha_client_f.post("/device/1/edit", data={
"name": "Dev G", "battery_slots": "1", "ha_entity_id": ""
"name": "Dev G", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": ""
})
resp = ha_client_f.get("/device/1")
assert b"sensor.foo" not in resp.data
@@ -415,11 +415,11 @@ def test_manual_battery_percentage_edit(ha_client_f):
def test_poll_skips_update_when_percentage_unchanged(ha_app, ha_client_f):
"""No write and no pct_log entry when the polled value matches the stored value."""
ha_client_f.post("/device/add", data={"name": "Dev NoCh", "battery_slots": "1"})
ha_client_f.post("/device/add", data={"name": "Dev NoCh", "battery_slots": "1", "battery_size": "AA"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev NoCh", "battery_slots": "1", "ha_entity_id": "sensor.noch"
"name": "Dev NoCh", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.noch"
})
from sqlalchemy import create_engine
@@ -446,11 +446,11 @@ def test_poll_skips_update_when_percentage_unchanged(ha_app, ha_client_f):
def test_poll_creates_pct_log_on_change(ha_app, ha_client_f):
"""A pct_log entry with source='poll' is written when the value changes."""
ha_client_f.post("/device/add", data={"name": "Dev Chg", "battery_slots": "1"})
ha_client_f.post("/device/add", data={"name": "Dev Chg", "battery_slots": "1", "battery_size": "AA"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev Chg", "battery_slots": "1", "ha_entity_id": "sensor.chg"
"name": "Dev Chg", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.chg"
})
from ha_client import HomeAssistantClient
@@ -519,9 +519,9 @@ def test_manual_edit_no_log_when_unchanged(ha_client_f):
def test_device_detail_shows_live_pct(ha_app, ha_client_f):
"""Opening a device page fetches live % from HA and displays it."""
ha_client_f.post("/device/add", data={"name": "Dev Live", "battery_slots": "1"})
ha_client_f.post("/device/add", data={"name": "Dev Live", "battery_slots": "1", "battery_size": "AA"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev Live", "battery_slots": "1", "ha_entity_id": "sensor.live_test"
"name": "Dev Live", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.live_test"
})
mock_resp = MagicMock()
@@ -537,11 +537,11 @@ def test_device_detail_shows_live_pct(ha_app, ha_client_f):
def test_device_detail_updates_battery_on_load(ha_app, ha_client_f):
"""Battery percentage is updated (with pct_log) when device page is loaded and value changed."""
ha_client_f.post("/device/add", data={"name": "Dev Update", "battery_slots": "1"})
ha_client_f.post("/device/add", data={"name": "Dev Update", "battery_slots": "1", "battery_size": "AA"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev Update", "battery_slots": "1", "ha_entity_id": "sensor.update_test"
"name": "Dev Update", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.update_test"
})
mock_resp = MagicMock()