Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b2029d3b8 | |||
| 52d1105997 | |||
| 3e75bb3ab4 |
@@ -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 import create_engine, func
|
||||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||||
|
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
import re
|
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:
|
def _parse_date(val: str) -> str | None:
|
||||||
@@ -231,7 +235,8 @@ def create_app(config_object="config"):
|
|||||||
pct_logs=pct_logs,
|
pct_logs=pct_logs,
|
||||||
charge_logs_data=charge_logs_data,
|
charge_logs_data=charge_logs_data,
|
||||||
capacity_tests_data=capacity_tests_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
|
# Battery — edit notes
|
||||||
@@ -634,8 +639,10 @@ def create_app(config_object="config"):
|
|||||||
devices = db.query(Device).order_by(Device.name).all()
|
devices = db.query(Device).order_by(Device.name).all()
|
||||||
device_types = sorted({d.device_type for d in devices if d.device_type})
|
device_types = sorted({d.device_type for d in devices if d.device_type})
|
||||||
device_locations = sorted({d.location for d in devices if d.location})
|
device_locations = sorted({d.location for d in devices if d.location})
|
||||||
|
device_battery_sizes = sorted({d.battery_size for d in devices if d.battery_size})
|
||||||
return render_template("device_list.html", devices=devices,
|
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
|
# Devices — add
|
||||||
@@ -646,19 +653,31 @@ def create_app(config_object="config"):
|
|||||||
all_devices = db.query(Device).all()
|
all_devices = db.query(Device).all()
|
||||||
device_types = sorted({d.device_type for d in all_devices if d.device_type})
|
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_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":
|
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()
|
||||||
notes = request.form.get("notes", "").strip() or None
|
notes = request.form.get("notes", "").strip() or None
|
||||||
device_type = request.form.get("device_type", "").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
|
location = request.form.get("location", "").strip() or None
|
||||||
|
|
||||||
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), 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:
|
try:
|
||||||
slots = int(slots_raw)
|
slots = int(slots_raw)
|
||||||
@@ -669,6 +688,7 @@ def create_app(config_object="config"):
|
|||||||
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,
|
||||||
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", "")), 400
|
||||||
|
|
||||||
@@ -677,19 +697,22 @@ def create_app(config_object="config"):
|
|||||||
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,
|
||||||
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", "")), 400
|
||||||
|
|
||||||
device = Device(name=name, battery_slots=slots, notes=notes,
|
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.add(device)
|
||||||
db.commit()
|
db.commit()
|
||||||
flash(f"Device '{name}' added.", "success")
|
flash(f"Device '{name}' added.", "success")
|
||||||
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)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — detail
|
# Devices — detail
|
||||||
@@ -700,12 +723,22 @@ def create_app(config_object="config"):
|
|||||||
device = db.get(Device, device_id)
|
device = db.get(Device, device_id)
|
||||||
if device is None:
|
if device is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()]
|
all_devices = db.query(Device).all()
|
||||||
available_batteries = (db.query(Battery)
|
brands_q = db.query(Battery.brand).filter(Battery.status == "available")
|
||||||
.filter_by(status="available")
|
if device.battery_size:
|
||||||
.order_by(Battery.label).all())
|
brands_q = brands_q.filter(
|
||||||
device_types = sorted({d.device_type for d in db.query(Device).all() if d.device_type})
|
(Battery.size == device.battery_size) | (Battery.size == None)
|
||||||
device_locations = sorted({d.location for d in db.query(Device).all() if d.location})
|
)
|
||||||
|
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
|
ha_live_pct = None
|
||||||
if ha_client.enabled and device.ha_entity_id:
|
if ha_client.enabled and device.ha_entity_id:
|
||||||
ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=1)
|
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,
|
available_batteries=available_batteries,
|
||||||
device_types=device_types,
|
device_types=device_types,
|
||||||
device_locations=device_locations,
|
device_locations=device_locations,
|
||||||
|
device_battery_sizes=device_battery_sizes,
|
||||||
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)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — edit
|
# Devices — edit
|
||||||
@@ -763,6 +798,9 @@ def create_app(config_object="config"):
|
|||||||
device.battery_slots = slots
|
device.battery_slots = slots
|
||||||
device.notes = notes
|
device.notes = notes
|
||||||
device.device_type = device_type
|
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.location = request.form.get("location", "").strip() or None
|
||||||
device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None
|
device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -807,11 +845,12 @@ def create_app(config_object="config"):
|
|||||||
|
|
||||||
# Validate availability before writing anything
|
# Validate availability before writing anything
|
||||||
for brand, qty in pairs:
|
for brand, qty in pairs:
|
||||||
available_count = (
|
avail_q = db.query(func.count(Battery.id)).filter_by(brand=brand, status="available")
|
||||||
db.query(func.count(Battery.id))
|
if device.battery_size:
|
||||||
.filter_by(brand=brand, status="available")
|
avail_q = avail_q.filter(
|
||||||
.scalar()
|
(Battery.size == device.battery_size) | (Battery.size == None)
|
||||||
)
|
)
|
||||||
|
available_count = avail_q.scalar()
|
||||||
if available_count < qty:
|
if available_count < qty:
|
||||||
flash(
|
flash(
|
||||||
f"Need {qty} {brand}, but only {available_count} available.",
|
f"Need {qty} {brand}, but only {available_count} available.",
|
||||||
@@ -822,13 +861,12 @@ def create_app(config_object="config"):
|
|||||||
# All checks passed — perform installs
|
# All checks passed — perform installs
|
||||||
total_installed = 0
|
total_installed = 0
|
||||||
for brand, qty in pairs:
|
for brand, qty in pairs:
|
||||||
batch = (
|
batch_q = db.query(Battery).filter_by(brand=brand, status="available")
|
||||||
db.query(Battery)
|
if device.battery_size:
|
||||||
.filter_by(brand=brand, status="available")
|
batch_q = batch_q.filter(
|
||||||
.order_by(Battery.id)
|
(Battery.size == device.battery_size) | (Battery.size == None)
|
||||||
.limit(qty)
|
)
|
||||||
.all()
|
batch = batch_q.order_by(Battery.id).limit(qty).all()
|
||||||
)
|
|
||||||
for b in batch:
|
for b in batch:
|
||||||
b.status = "installed"
|
b.status = "installed"
|
||||||
b.device_id = device.id
|
b.device_id = device.id
|
||||||
@@ -870,6 +908,12 @@ def create_app(config_object="config"):
|
|||||||
f"{', '.join(existing_brands)}. Mixing brands is not recommended.",
|
f"{', '.join(existing_brands)}. Mixing brands is not recommended.",
|
||||||
"warning",
|
"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.status = "installed"
|
||||||
battery.device_id = device.id
|
battery.device_id = device.id
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -916,6 +960,406 @@ def create_app(config_object="config"):
|
|||||||
)
|
)
|
||||||
return redirect(url_for("device_list"))
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
from datetime import datetime
|
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
|
from sqlalchemy.orm import declarative_base, relationship
|
||||||
|
|
||||||
Base = declarative_base()
|
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):
|
class Device(Base):
|
||||||
__tablename__ = "device"
|
__tablename__ = "device"
|
||||||
@@ -13,11 +28,18 @@ class Device(Base):
|
|||||||
name = Column(String(100), nullable=False, unique=True)
|
name = Column(String(100), nullable=False, unique=True)
|
||||||
battery_slots = Column(Integer, nullable=False, default=1)
|
battery_slots = Column(Integer, nullable=False, default=1)
|
||||||
device_type = Column(String(50), nullable=True)
|
device_type = Column(String(50), nullable=True)
|
||||||
|
battery_size = Column(String(20), nullable=False) # AA, AAA, 9V, CR2032 …
|
||||||
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"
|
||||||
|
|
||||||
batteries = relationship("Battery", back_populates="device")
|
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):
|
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")
|
||||||
@@ -69,6 +91,12 @@ class Battery(Base):
|
|||||||
order_by="BatteryPctLog.recorded_at.desc()",
|
order_by="BatteryPctLog.recorded_at.desc()",
|
||||||
cascade="all, delete-orphan",
|
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):
|
def is_available(self):
|
||||||
return self.status == "available"
|
return self.status == "available"
|
||||||
@@ -126,3 +154,18 @@ class BatteryPctLog(Base):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<BatteryPctLog {self.battery_id} {self.recorded_at} {self.percentage}%>"
|
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}>"
|
||||||
|
|||||||
@@ -334,6 +334,20 @@
|
|||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</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" role="dialog" aria-modal="true">
|
||||||
<div id="confirm-modal-box">
|
<div id="confirm-modal-box">
|
||||||
<p id="confirm-modal-msg"></p>
|
<p id="confirm-modal-msg"></p>
|
||||||
|
|||||||
@@ -216,6 +216,38 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 -->
|
<!-- Edit Details -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Edit Details</h2>
|
<h2>Edit Details</h2>
|
||||||
|
|||||||
@@ -18,6 +18,27 @@
|
|||||||
value="{{ form_slots|default(1) }}" min="1" required>
|
value="{{ form_slots|default(1) }}" min="1" required>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label>Type</label>
|
<label>Type</label>
|
||||||
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
|
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
|
||||||
|
|||||||
@@ -39,6 +39,12 @@
|
|||||||
<td style="border:none;">{{ device.device_type }}</td>
|
<td style="border:none;">{{ device.device_type }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% 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() %}
|
{% if device.has_mixed_brands() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Warning</td>
|
<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">
|
<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() %}
|
||||||
<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 %} — showing {{ device.battery_size }} batteries only{% endif %}</p>
|
||||||
<form method="post" action="{{ url_for('device_install', device_id=device.id) }}">
|
<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;">
|
<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>
|
<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;
|
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) {
|
function editLocationSelectChanged(sel) {
|
||||||
var input = document.getElementById('edit-location');
|
var input = document.getElementById('edit-location');
|
||||||
if (sel.value === '__new__') {
|
if (sel.value === '__new__') {
|
||||||
@@ -186,7 +200,7 @@ function addInstallRow() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<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 %}
|
{% if available_batteries %}
|
||||||
<form method="post" action="{{ url_for('device_install_one', device_id=device.id) }}">
|
<form method="post" action="{{ url_for('device_install_one', device_id=device.id) }}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -207,6 +221,37 @@ function addInstallRow() {
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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">
|
<div class="card">
|
||||||
<h2>Edit Device</h2>
|
<h2>Edit Device</h2>
|
||||||
<form method="post" action="{{ url_for('device_edit', device_id=device.id) }}">
|
<form method="post" action="{{ url_for('device_edit', device_id=device.id) }}">
|
||||||
@@ -238,6 +283,29 @@ function addInstallRow() {
|
|||||||
placeholder="Enter device type"
|
placeholder="Enter device type"
|
||||||
style="display:{% if device.device_type and device.device_type not in _preset_types %}''{% else %}none{% endif %};margin-top:0.4rem;">
|
style="display:{% if device.device_type and device.device_type not in _preset_types %}''{% else %}none{% endif %};margin-top:0.4rem;">
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
<div class="form-group">
|
||||||
<label>Location</label>
|
<label>Location</label>
|
||||||
<select id="edit-location-select" onchange="editLocationSelectChanged(this)">
|
<select id="edit-location-select" onchange="editLocationSelectChanged(this)">
|
||||||
|
|||||||
+33
-18
@@ -20,6 +20,13 @@
|
|||||||
<option value="{{ loc }}">{{ loc }}</option>
|
<option value="{{ loc }}">{{ loc }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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()"
|
<select id="filter-fill" onchange="applyDeviceFilters()"
|
||||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||||
<option value="">Any Fill</option>
|
<option value="">Any Fill</option>
|
||||||
@@ -40,6 +47,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
|
<th>Size</th>
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
<th>Slots</th>
|
<th>Slots</th>
|
||||||
<th>Installed</th>
|
<th>Installed</th>
|
||||||
@@ -54,11 +62,13 @@
|
|||||||
{% elif installed >= d.battery_slots %}{% set fill_state = 'full' %}
|
{% elif installed >= d.battery_slots %}{% set fill_state = 'full' %}
|
||||||
{% else %}{% set fill_state = 'partial' %}{% endif %}
|
{% else %}{% set fill_state = 'partial' %}{% endif %}
|
||||||
<tr data-type="{{ d.device_type or '' }}"
|
<tr data-type="{{ d.device_type or '' }}"
|
||||||
|
data-battery-size="{{ d.battery_size or '' }}"
|
||||||
data-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></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="Location">{{ d.location or '—' }}</td>
|
<td data-label="Location">{{ d.location or '—' }}</td>
|
||||||
<td data-label="Slots">{{ d.battery_slots }}</td>
|
<td data-label="Slots">{{ d.battery_slots }}</td>
|
||||||
<td data-label="Installed">
|
<td data-label="Installed">
|
||||||
@@ -95,7 +105,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -106,27 +116,31 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
function applyDeviceFilters() {
|
function applyDeviceFilters() {
|
||||||
var typeVal = document.getElementById('filter-type').value;
|
var typeVal = document.getElementById('filter-type').value;
|
||||||
var locationVal = document.getElementById('filter-location').value;
|
var batterySizeVal = document.getElementById('filter-battery-size').value;
|
||||||
var fillVal = document.getElementById('filter-fill').value;
|
var locationVal = document.getElementById('filter-location').value;
|
||||||
var textVal = document.getElementById('filter-device-text').value.toLowerCase();
|
var fillVal = document.getElementById('filter-fill').value;
|
||||||
var rows = document.querySelectorAll('tbody tr[data-name]');
|
var textVal = document.getElementById('filter-device-text').value.toLowerCase();
|
||||||
var visible = 0;
|
var rows = document.querySelectorAll('tbody tr[data-name]');
|
||||||
|
var visible = 0;
|
||||||
rows.forEach(function(row) {
|
rows.forEach(function(row) {
|
||||||
var rowType = row.dataset.type || '';
|
var rowType = row.dataset.type || '';
|
||||||
var rowLocation = row.dataset.location || '';
|
var rowBatterySize = row.dataset.batterySize || '';
|
||||||
var rowFill = row.dataset.fill || '';
|
var rowLocation = row.dataset.location || '';
|
||||||
var rowName = row.dataset.name || '';
|
var rowFill = row.dataset.fill || '';
|
||||||
var show = (!typeVal || rowType === typeVal) &&
|
var rowName = row.dataset.name || '';
|
||||||
(!locationVal || rowLocation === locationVal) &&
|
var show = (!typeVal || rowType === typeVal) &&
|
||||||
(!fillVal || rowFill === fillVal) &&
|
(!batterySizeVal || rowBatterySize === batterySizeVal) &&
|
||||||
(!textVal || rowName.includes(textVal) ||
|
(!locationVal || rowLocation === locationVal) &&
|
||||||
rowType.toLowerCase().includes(textVal) ||
|
(!fillVal || rowFill === fillVal) &&
|
||||||
rowLocation.toLowerCase().includes(textVal));
|
(!textVal || rowName.includes(textVal) ||
|
||||||
|
rowType.toLowerCase().includes(textVal) ||
|
||||||
|
rowBatterySize.toLowerCase().includes(textVal) ||
|
||||||
|
rowLocation.toLowerCase().includes(textVal));
|
||||||
row.style.display = show ? '' : 'none';
|
row.style.display = show ? '' : 'none';
|
||||||
if (show) visible++;
|
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-reset').style.display = active ? '' : 'none';
|
||||||
document.getElementById('device-filter-count').textContent =
|
document.getElementById('device-filter-count').textContent =
|
||||||
active ? (visible + ' of ' + rows.length + ' shown') : '';
|
active ? (visible + ' of ' + rows.length + ' shown') : '';
|
||||||
@@ -134,6 +148,7 @@ function applyDeviceFilters() {
|
|||||||
|
|
||||||
function resetDeviceFilters() {
|
function resetDeviceFilters() {
|
||||||
document.getElementById('filter-type').value = '';
|
document.getElementById('filter-type').value = '';
|
||||||
|
document.getElementById('filter-battery-size').value = '';
|
||||||
document.getElementById('filter-location').value = '';
|
document.getElementById('filter-location').value = '';
|
||||||
document.getElementById('filter-fill').value = '';
|
document.getElementById('filter-fill').value = '';
|
||||||
document.getElementById('filter-device-text').value = '';
|
document.getElementById('filter-device-text').value = '';
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -42,8 +42,8 @@ def seeded_client(app):
|
|||||||
id=3 BrandX 002 (BrandX, retired)
|
id=3 BrandX 002 (BrandX, retired)
|
||||||
"""
|
"""
|
||||||
with app.test_client() as c:
|
with app.test_client() as c:
|
||||||
c.post("/device/add", data={"name": "Device A", "battery_slots": "2"})
|
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"})
|
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": "BrandX", "count": "1"}) # id=1
|
||||||
c.post("/battery/add", data={"brand": "BrandY", "count": "1"}) # id=2
|
c.post("/battery/add", data={"brand": "BrandY", "count": "1"}) # id=2
|
||||||
c.post("/battery/add", data={"brand": "BrandX", "count": "1"}) # id=3
|
c.post("/battery/add", data={"brand": "BrandX", "count": "1"}) # id=3
|
||||||
|
|||||||
+275
-21
@@ -235,7 +235,7 @@ def test_bulk_delete(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_bulk_unassign(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("/battery/add", data={"brand": "TestBrand", "count": "2"})
|
||||||
client.post("/device/1/install", data={"brand[]": "TestBrand", "qty[]": "2"})
|
client.post("/device/1/install", data={"brand[]": "TestBrand", "qty[]": "2"})
|
||||||
resp = client.post("/battery/bulk-action",
|
resp = client.post("/battery/bulk-action",
|
||||||
@@ -269,7 +269,7 @@ def test_bulk_action_no_selection(client):
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_device_install_autoselects(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"})
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "3"})
|
||||||
resp = client.post("/device/1/install",
|
resp = client.post("/device/1/install",
|
||||||
data={"brand[]": "Eneloop", "qty[]": "2"},
|
data={"brand[]": "Eneloop", "qty[]": "2"},
|
||||||
@@ -281,7 +281,7 @@ def test_device_install_autoselects(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_device_install_mixed_brands(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": "Eneloop", "count": "2"})
|
||||||
client.post("/battery/add", data={"brand": "Energizer", "count": "2"})
|
client.post("/battery/add", data={"brand": "Energizer", "count": "2"})
|
||||||
resp = client.post("/device/1/install",
|
resp = client.post("/device/1/install",
|
||||||
@@ -293,7 +293,7 @@ def test_device_install_mixed_brands(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_device_install_insufficient_batteries(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"})
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
resp = client.post("/device/1/install",
|
resp = client.post("/device/1/install",
|
||||||
data={"brand[]": "Eneloop", "qty[]": "2"},
|
data={"brand[]": "Eneloop", "qty[]": "2"},
|
||||||
@@ -303,7 +303,7 @@ def test_device_install_insufficient_batteries(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_device_install_over_capacity(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"})
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "3"})
|
||||||
resp = client.post("/device/1/install",
|
resp = client.post("/device/1/install",
|
||||||
data={"brand[]": "Eneloop", "qty[]": "3"},
|
data={"brand[]": "Eneloop", "qty[]": "3"},
|
||||||
@@ -314,7 +314,7 @@ def test_device_install_over_capacity(client):
|
|||||||
|
|
||||||
def test_bulk_install_device(client):
|
def test_bulk_install_device(client):
|
||||||
"""Select multiple available batteries and install them into a device."""
|
"""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"})
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
|
||||||
resp = client.post("/battery/bulk-action",
|
resp = client.post("/battery/bulk-action",
|
||||||
data={"battery_ids": ["1", "2"], "action": "install_device",
|
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):
|
def test_bulk_install_device_over_capacity(client):
|
||||||
"""Bulk install is blocked when device lacks free slots."""
|
"""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"})
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
|
||||||
resp = client.post("/battery/bulk-action",
|
resp = client.post("/battery/bulk-action",
|
||||||
data={"battery_ids": ["1", "2"], "action": "install_device",
|
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):
|
def test_bulk_install_device_moves_installed_battery(client):
|
||||||
"""Bulk install moves a battery already installed in another device."""
|
"""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 A", "battery_slots": "2", "battery_size": "AA"})
|
||||||
client.post("/device/add", data={"name": "Box B", "battery_slots": "2"})
|
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/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
client.post("/battery/1/assign", data={"device_id": "1"})
|
client.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
resp = client.post("/battery/bulk-action",
|
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):
|
def test_dashboard_quick_assign(client):
|
||||||
"""Quick-assign from dashboard (battery_assign POST) succeeds."""
|
"""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"})
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
resp = client.post("/battery/1/assign", data={"device_id": "1"},
|
resp = client.post("/battery/1/assign", data={"device_id": "1"},
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
@@ -367,7 +367,7 @@ def test_dashboard_quick_assign(client):
|
|||||||
|
|
||||||
def test_dashboard_quick_assign_full_device_blocked(client):
|
def test_dashboard_quick_assign_full_device_blocked(client):
|
||||||
"""Quick-assign from dashboard to a full device is blocked."""
|
"""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/add", data={"brand": "Eneloop", "count": "2"})
|
||||||
client.post("/battery/1/assign", data={"device_id": "1"}) # fills the slot
|
client.post("/battery/1/assign", data={"device_id": "1"}) # fills the slot
|
||||||
resp = client.post("/battery/2/assign", data={"device_id": "1"},
|
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):
|
def test_install_one_specific_battery(client):
|
||||||
"""device_install_one installs a chosen battery."""
|
"""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"})
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
resp = client.post("/device/1/install-one", data={"battery_id": "1"},
|
resp = client.post("/device/1/install-one", data={"battery_id": "1"},
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
@@ -392,7 +392,7 @@ def test_install_one_specific_battery(client):
|
|||||||
|
|
||||||
def test_install_one_over_capacity_blocked(client):
|
def test_install_one_over_capacity_blocked(client):
|
||||||
"""device_install_one to a full device is blocked."""
|
"""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("/battery/add", data={"brand": "Eneloop", "count": "2"})
|
||||||
client.post("/device/1/install-one", data={"battery_id": "1"}) # fills slot
|
client.post("/device/1/install-one", data={"battery_id": "1"}) # fills slot
|
||||||
resp = client.post("/device/1/install-one", data={"battery_id": "2"},
|
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):
|
def test_add_device(client):
|
||||||
resp = client.post("/device/add",
|
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)
|
follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"My Gadget" in resp.data
|
assert b"My Gadget" in resp.data
|
||||||
@@ -426,7 +426,7 @@ def test_add_device(client):
|
|||||||
|
|
||||||
def test_add_device_duplicate_name(seeded_client):
|
def test_add_device_duplicate_name(seeded_client):
|
||||||
resp = seeded_client.post("/device/add",
|
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 resp.status_code == 400
|
||||||
assert b"already exists" in resp.data
|
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):
|
def test_add_device_with_type(client):
|
||||||
resp = client.post("/device/add",
|
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)
|
follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"TV Remote" in resp.data
|
assert b"TV Remote" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_device_detail_shows_type(client):
|
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")
|
resp = client.get("/device/1")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Flashlight" in resp.data
|
assert b"Flashlight" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_edit_device_type(client):
|
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",
|
resp = client.post("/device/1/edit",
|
||||||
data={"name": "Torch", "battery_slots": "1", "device_type": "Flashlight"},
|
data={"name": "Torch", "battery_slots": "1", "device_type": "Flashlight"},
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
@@ -500,7 +501,8 @@ def test_edit_device_type(client):
|
|||||||
|
|
||||||
def test_add_device_with_location(client):
|
def test_add_device_with_location(client):
|
||||||
resp = client.post("/device/add",
|
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)
|
follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Bedroom Remote" in resp.data
|
assert b"Bedroom Remote" in resp.data
|
||||||
@@ -509,7 +511,7 @@ def test_add_device_with_location(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_edit_device_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",
|
resp = client.post("/device/1/edit",
|
||||||
data={"name": "Living Room Clock", "battery_slots": "1", "location": "Living Room"},
|
data={"name": "Living Room Clock", "battery_slots": "1", "location": "Living Room"},
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
@@ -545,7 +547,7 @@ def test_delete_capacity_test(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_add_install_delete_battery(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"})
|
client.post("/battery/add", data={"brand": "AcmeBrand", "count": "1"})
|
||||||
resp = client.post("/device/1/install",
|
resp = client.post("/device/1/install",
|
||||||
data={"brand[]": "AcmeBrand", "qty[]": "1"},
|
data={"brand[]": "AcmeBrand", "qty[]": "1"},
|
||||||
@@ -553,3 +555,255 @@ def test_add_install_delete_battery(client):
|
|||||||
assert b"Gadget" in resp.data
|
assert b"Gadget" in resp.data
|
||||||
client.post("/battery/1/delete")
|
client.post("/battery/1/delete")
|
||||||
assert client.get("/battery/1").status_code == 404
|
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
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def test_ha_disabled_dashboard_no_ha_column(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_ha_disabled_device_no_ha_field(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")
|
resp = client.get("/device/1")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"ha_entity_id" not in resp.data
|
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):
|
def test_poll_updates_installed_batteries(ha_app, ha_client_f):
|
||||||
"""Installed battery in a device with ha_entity_id gets battery_percentage updated."""
|
"""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"})
|
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
|
||||||
# Assign battery to device
|
# Assign battery to device
|
||||||
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
# Set ha_entity_id on device
|
# Set ha_entity_id on device
|
||||||
ha_client_f.post("/device/1/edit", data={
|
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
|
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):
|
def test_poll_skips_uninstalled_batteries(ha_app, ha_client_f):
|
||||||
"""Batteries that are available (not installed) are not updated by the poller."""
|
"""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"})
|
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
|
||||||
# Set entity on device but do NOT install the battery
|
# Set entity on device but do NOT install the battery
|
||||||
ha_client_f.post("/device/1/edit", data={
|
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
|
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):
|
def test_poll_skips_devices_without_entity_id(ha_app, ha_client_f):
|
||||||
"""Devices with no ha_entity_id must never reach get_state."""
|
"""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_client import HomeAssistantClient
|
||||||
from ha_poller import HaPoller
|
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):
|
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."""
|
"""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/add", data={"brand": "X", "count": "1"})
|
||||||
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
ha_client_f.post("/device/1/edit", data={
|
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
|
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):
|
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")
|
resp = ha_client_f.get("/device/1")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"ha_entity_id" in resp.data
|
assert b"ha_entity_id" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_edit_device_ha_entity_id_saves(ha_client_f):
|
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={
|
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")
|
resp = ha_client_f.get("/device/1")
|
||||||
assert b"sensor.my_remote" in resp.data
|
assert b"sensor.my_remote" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_edit_device_ha_entity_id_clear(ha_client_f):
|
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={
|
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
|
# Now clear it
|
||||||
ha_client_f.post("/device/1/edit", data={
|
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")
|
resp = ha_client_f.get("/device/1")
|
||||||
assert b"sensor.foo" not in resp.data
|
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):
|
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."""
|
"""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/add", data={"brand": "X", "count": "1"})
|
||||||
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
ha_client_f.post("/device/1/edit", data={
|
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
|
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):
|
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."""
|
"""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/add", data={"brand": "X", "count": "1"})
|
||||||
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
ha_client_f.post("/device/1/edit", data={
|
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
|
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):
|
def test_device_detail_shows_live_pct(ha_app, ha_client_f):
|
||||||
"""Opening a device page fetches live % from HA and displays it."""
|
"""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={
|
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()
|
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):
|
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."""
|
"""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/add", data={"brand": "X", "count": "1"})
|
||||||
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
ha_client_f.post("/device/1/edit", data={
|
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()
|
mock_resp = MagicMock()
|
||||||
|
|||||||
Reference in New Issue
Block a user