Add logbook entries, data export page, and JSON import

This commit is contained in:
2026-04-26 20:03:58 -05:00
parent 52d1105997
commit 3b2029d3b8
8 changed files with 877 additions and 6 deletions
+410 -4
View File
@@ -1,11 +1,15 @@
from flask import Flask, render_template, redirect, url_for, request, flash, abort, send_from_directory, jsonify
from flask import Flask, render_template, redirect, url_for, request, flash, abort, send_from_directory, jsonify, Response
from sqlalchemy import create_engine, func
from sqlalchemy.orm import scoped_session, sessionmaker
from datetime import datetime, date, timedelta
import csv
import io
import json
import re
import zipfile
from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog
from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog, Logbook
def _parse_date(val: str) -> str | None:
@@ -231,7 +235,8 @@ def create_app(config_object="config"):
pct_logs=pct_logs,
charge_logs_data=charge_logs_data,
capacity_tests_data=capacity_tests_data,
pct_logs_data=pct_logs_data)
pct_logs_data=pct_logs_data,
logbook_entries=battery.logbook_entries)
# ------------------------------------------------------------------ #
# Battery — edit notes
@@ -757,7 +762,8 @@ def create_app(config_object="config"):
device_locations=device_locations,
device_battery_sizes=device_battery_sizes,
ha_enabled=ha_client.enabled,
ha_live_pct=ha_live_pct)
ha_live_pct=ha_live_pct,
logbook_entries=device.logbook_entries)
# ------------------------------------------------------------------ #
# Devices — edit
@@ -954,6 +960,406 @@ def create_app(config_object="config"):
)
return redirect(url_for("device_list"))
# ------------------------------------------------------------------ #
# Export
# ------------------------------------------------------------------ #
def _batteries_csv():
rows = db.query(Battery).order_by(Battery.id).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "label", "brand", "status", "device_id", "device_name",
"size", "chemistry", "capacity_mah", "tested_capacity_mah",
"tested_date", "charge_cycles", "purchase_date",
"storage_location", "battery_percentage", "notes"])
for b in rows:
w.writerow([b.id, b.label, b.brand, b.status, b.device_id or "",
b.device.name if b.device else "",
b.size or "", b.chemistry or "", b.capacity_mah or "",
b.tested_capacity_mah or "", b.tested_date or "",
b.charge_cycles or "", b.purchase_date or "",
b.storage_location or "", b.battery_percentage or "", b.notes or ""])
return buf.getvalue()
def _devices_csv():
rows = db.query(Device).order_by(Device.id).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "name", "battery_slots", "installed_count",
"device_type", "battery_size", "location", "ha_entity_id", "notes"])
for d in rows:
w.writerow([d.id, d.name, d.battery_slots, d.installed_count(),
d.device_type or "", d.battery_size or "",
d.location or "", d.ha_entity_id or "", d.notes or ""])
return buf.getvalue()
def _charge_logs_csv():
rows = db.query(ChargeLog).order_by(ChargeLog.id).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "battery_id", "battery_label", "charged_date", "increment_cycles", "notes"])
for l in rows:
w.writerow([l.id, l.battery_id, l.battery.label,
l.charged_date, l.increment_cycles, l.notes or ""])
return buf.getvalue()
def _capacity_tests_csv():
rows = db.query(CapacityTest).order_by(CapacityTest.id).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "battery_id", "battery_label", "tested_capacity_mah", "tested_date", "notes"])
for t in rows:
w.writerow([t.id, t.battery_id, t.battery.label,
t.tested_capacity_mah, t.tested_date, t.notes or ""])
return buf.getvalue()
def _pct_logs_csv():
rows = db.query(BatteryPctLog).order_by(BatteryPctLog.id).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "battery_id", "battery_label", "percentage", "recorded_at", "source"])
for l in rows:
w.writerow([l.id, l.battery_id, l.battery.label,
l.percentage, l.recorded_at, l.source or ""])
return buf.getvalue()
@app.route("/export")
def export_page():
return render_template("export.html")
@app.route("/export/batteries.csv")
def export_batteries_csv():
return Response(_batteries_csv(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=batteries.csv"})
@app.route("/export/devices.csv")
def export_devices_csv():
return Response(_devices_csv(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=devices.csv"})
@app.route("/export/charge-logs.csv")
def export_charge_logs_csv():
return Response(_charge_logs_csv(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=charge-logs.csv"})
@app.route("/export/capacity-tests.csv")
def export_capacity_tests_csv():
return Response(_capacity_tests_csv(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=capacity-tests.csv"})
@app.route("/export/pct-logs.csv")
def export_pct_logs_csv():
return Response(_pct_logs_csv(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=pct-logs.csv"})
@app.route("/export/csv.zip")
def export_csv_zip():
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("batteries.csv", _batteries_csv())
zf.writestr("devices.csv", _devices_csv())
zf.writestr("charge-logs.csv", _charge_logs_csv())
zf.writestr("capacity-tests.csv", _capacity_tests_csv())
zf.writestr("pct-logs.csv", _pct_logs_csv())
zip_buf.seek(0)
fname = f"battery-tracker-{datetime.utcnow().strftime('%Y%m%d')}.zip"
return Response(zip_buf.read(), mimetype="application/zip",
headers={"Content-Disposition": f"attachment; filename={fname}"})
@app.route("/export/all.json")
def export_json():
batteries = db.query(Battery).order_by(Battery.id).all()
devices = db.query(Device).order_by(Device.id).all()
charge_logs = db.query(ChargeLog).order_by(ChargeLog.id).all()
capacity_tests = db.query(CapacityTest).order_by(CapacityTest.id).all()
pct_logs = db.query(BatteryPctLog).order_by(BatteryPctLog.id).all()
payload = {
"exported_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
"batteries": [
{"id": b.id, "label": b.label, "brand": b.brand, "status": b.status,
"device_id": b.device_id, "device_name": b.device.name if b.device else None,
"size": b.size, "chemistry": b.chemistry, "capacity_mah": b.capacity_mah,
"tested_capacity_mah": b.tested_capacity_mah, "tested_date": b.tested_date,
"charge_cycles": b.charge_cycles, "purchase_date": b.purchase_date,
"storage_location": b.storage_location,
"battery_percentage": b.battery_percentage, "notes": b.notes}
for b in batteries
],
"devices": [
{"id": d.id, "name": d.name, "battery_slots": d.battery_slots,
"installed_count": d.installed_count(), "device_type": d.device_type,
"battery_size": d.battery_size, "location": d.location,
"ha_entity_id": d.ha_entity_id, "notes": d.notes}
for d in devices
],
"charge_logs": [
{"id": l.id, "battery_id": l.battery_id, "battery_label": l.battery.label,
"charged_date": l.charged_date, "increment_cycles": l.increment_cycles,
"notes": l.notes}
for l in charge_logs
],
"capacity_tests": [
{"id": t.id, "battery_id": t.battery_id, "battery_label": t.battery.label,
"tested_capacity_mah": t.tested_capacity_mah, "tested_date": t.tested_date,
"notes": t.notes}
for t in capacity_tests
],
"pct_logs": [
{"id": l.id, "battery_id": l.battery_id, "battery_label": l.battery.label,
"percentage": l.percentage, "recorded_at": l.recorded_at, "source": l.source}
for l in pct_logs
],
}
return Response(json.dumps(payload, indent=2), mimetype="application/json",
headers={"Content-Disposition": "attachment; filename=battery-tracker-export.json"})
# ------------------------------------------------------------------ #
# Import
# ------------------------------------------------------------------ #
@app.route("/import", methods=["GET", "POST"])
def import_page():
if request.method == "GET":
return render_template("import.html", results=None)
# --- file presence ---
if "file" not in request.files or request.files["file"].filename == "":
flash("No file selected.", "error")
return render_template("import.html", results=None), 400
f = request.files["file"]
# --- size check (10 MB) ---
f.seek(0, 2)
size = f.tell()
f.seek(0)
if size > 10 * 1024 * 1024:
flash("File too large (max 10 MB).", "error")
return render_template("import.html", results=None), 400
# --- JSON parse ---
try:
data = json.load(f)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
flash(f"Invalid JSON file: {e}", "error")
return render_template("import.html", results=None), 400
# --- key validation ---
if not isinstance(data, dict) or "batteries" not in data or "devices" not in data:
flash("Invalid format: JSON must contain 'batteries' and 'devices' keys.", "error")
return render_template("import.html", results=None), 400
device_id_map = {}
battery_id_map = {}
devices_created = devices_skipped = 0
batteries_created = batteries_skipped = 0
charge_logs_appended = charge_logs_skipped = 0
capacity_tests_appended = capacity_tests_skipped = 0
pct_logs_appended = pct_logs_skipped = 0
try:
# --- devices ---
for d in data.get("devices", []):
old_id = d.get("id")
name = (d.get("name") or "").strip()
if not name:
devices_skipped += 1
continue
existing = db.query(Device).filter_by(name=name).first()
if existing:
if old_id is not None:
device_id_map[old_id] = existing.id
devices_skipped += 1
else:
new_dev = Device(
name = name,
battery_slots = d.get("battery_slots") or 1,
device_type = d.get("device_type") or None,
battery_size = d.get("battery_size") or "",
location = d.get("location") or None,
ha_entity_id = d.get("ha_entity_id") or None,
notes = d.get("notes") or None,
)
db.add(new_dev)
db.flush()
if old_id is not None:
device_id_map[old_id] = new_dev.id
devices_created += 1
# --- batteries ---
for b in data.get("batteries", []):
old_id = b.get("id")
label = (b.get("label") or "").strip()
if not label:
batteries_skipped += 1
continue
existing = db.query(Battery).filter_by(label=label).first()
if existing:
if old_id is not None:
battery_id_map[old_id] = existing.id
batteries_skipped += 1
else:
old_device_id = b.get("device_id")
new_device_id = device_id_map.get(old_device_id) if old_device_id is not None else None
source_status = b.get("status", "available")
if new_device_id is not None:
status = "installed"
elif source_status == "retired":
status = "retired"
else:
status = "available"
new_bat = Battery(
label = label,
brand = b.get("brand") or "Unknown",
status = status,
device_id = new_device_id,
size = b.get("size") or None,
chemistry = b.get("chemistry") or None,
capacity_mah = b.get("capacity_mah") or None,
tested_capacity_mah = b.get("tested_capacity_mah") or None,
tested_date = b.get("tested_date") or None,
charge_cycles = b.get("charge_cycles") or None,
purchase_date = b.get("purchase_date") or None,
storage_location = b.get("storage_location") or None,
battery_percentage = b.get("battery_percentage") or None,
notes = b.get("notes") or None,
)
db.add(new_bat)
db.flush()
if old_id is not None:
battery_id_map[old_id] = new_bat.id
batteries_created += 1
# --- charge logs ---
for cl in data.get("charge_logs", []):
old_bat_id = cl.get("battery_id")
new_bat_id = battery_id_map.get(old_bat_id)
if new_bat_id is None:
charge_logs_skipped += 1
continue
db.add(ChargeLog(
battery_id = new_bat_id,
charged_date = cl.get("charged_date") or date.today().isoformat(),
increment_cycles = cl.get("increment_cycles") or 0,
notes = cl.get("notes") or None,
))
charge_logs_appended += 1
# --- capacity tests ---
for ct in data.get("capacity_tests", []):
old_bat_id = ct.get("battery_id")
new_bat_id = battery_id_map.get(old_bat_id)
if new_bat_id is None:
capacity_tests_skipped += 1
continue
db.add(CapacityTest(
battery_id = new_bat_id,
tested_capacity_mah = ct.get("tested_capacity_mah") or 0,
tested_date = ct.get("tested_date") or date.today().isoformat(),
notes = ct.get("notes") or None,
))
capacity_tests_appended += 1
# --- pct logs ---
for pl in data.get("pct_logs", []):
old_bat_id = pl.get("battery_id")
new_bat_id = battery_id_map.get(old_bat_id)
if new_bat_id is None:
pct_logs_skipped += 1
continue
db.add(BatteryPctLog(
battery_id = new_bat_id,
percentage = pl.get("percentage") or 0,
recorded_at = pl.get("recorded_at") or datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
source = pl.get("source") or None,
))
pct_logs_appended += 1
db.commit()
except Exception as e:
db.rollback()
flash(f"Import failed: {e}", "error")
return render_template("import.html", results=None), 500
flash(
f"Import complete: {devices_created} device(s) and {batteries_created} battery/ies created.",
"success",
)
results = {
"devices_created": devices_created,
"devices_skipped": devices_skipped,
"batteries_created": batteries_created,
"batteries_skipped": batteries_skipped,
"charge_logs_appended": charge_logs_appended,
"charge_logs_skipped": charge_logs_skipped,
"capacity_tests_appended": capacity_tests_appended,
"capacity_tests_skipped": capacity_tests_skipped,
"pct_logs_appended": pct_logs_appended,
"pct_logs_skipped": pct_logs_skipped,
}
return render_template("import.html", results=results)
# ------------------------------------------------------------------ #
# Logbook
# ------------------------------------------------------------------ #
@app.route("/battery/<int:battery_id>/logbook/add", methods=["POST"])
def battery_logbook_add(battery_id):
battery = db.get(Battery, battery_id)
if battery is None:
abort(404)
body = request.form.get("body", "").strip()
if not body:
flash("Entry text is required.", "error")
else:
battery.logbook_entries.append(
Logbook(body=body, recorded_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"))
)
db.commit()
flash("Logbook entry added.", "success")
return redirect(url_for("battery_detail", battery_id=battery_id))
@app.route("/battery/<int:battery_id>/logbook/<int:entry_id>/delete", methods=["POST"])
def battery_logbook_delete(battery_id, entry_id):
battery = db.get(Battery, battery_id)
if battery is None:
abort(404)
entry = db.get(Logbook, entry_id)
if entry and entry in battery.logbook_entries:
battery.logbook_entries.remove(entry)
db.commit()
flash("Logbook entry deleted.", "success")
return redirect(url_for("battery_detail", battery_id=battery_id))
@app.route("/device/<int:device_id>/logbook/add", methods=["POST"])
def device_logbook_add(device_id):
device = db.get(Device, device_id)
if device is None:
abort(404)
body = request.form.get("body", "").strip()
if not body:
flash("Entry text is required.", "error")
else:
device.logbook_entries.append(
Logbook(body=body, recorded_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"))
)
db.commit()
flash("Logbook entry added.", "success")
return redirect(url_for("device_detail", device_id=device_id))
@app.route("/device/<int:device_id>/logbook/<int:entry_id>/delete", methods=["POST"])
def device_logbook_delete(device_id, entry_id):
device = db.get(Device, device_id)
if device is None:
abort(404)
entry = db.get(Logbook, entry_id)
if entry and entry in device.logbook_entries:
device.logbook_entries.remove(entry)
db.commit()
flash("Logbook entry deleted.", "success")
return redirect(url_for("device_detail", device_id=device_id))
return app
+43 -1
View File
@@ -1,10 +1,25 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, ForeignKey
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table
from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base()
# Association tables for logbook (defined before ORM classes so they can be
# referenced as `secondary` in relationship()). String-based FKs are resolved
# lazily when create_all() binds the metadata.
battery_logbook_table = Table(
"battery_logbook", Base.metadata,
Column("battery_id", Integer, ForeignKey("battery.id", ondelete="CASCADE"), primary_key=True),
Column("logbook_id", Integer, ForeignKey("logbook.id", ondelete="CASCADE"), primary_key=True),
)
device_logbook_table = Table(
"device_logbook", Base.metadata,
Column("device_id", Integer, ForeignKey("device.id", ondelete="CASCADE"), primary_key=True),
Column("logbook_id", Integer, ForeignKey("logbook.id", ondelete="CASCADE"), primary_key=True),
)
class Device(Base):
__tablename__ = "device"
@@ -19,6 +34,12 @@ class Device(Base):
ha_entity_id = Column(String(100), nullable=True) # e.g. "sensor.tv_remote_battery"
batteries = relationship("Battery", back_populates="device")
logbook_entries = relationship(
"Logbook", secondary=device_logbook_table,
order_by="Logbook.recorded_at.desc()",
cascade="all, delete-orphan",
single_parent=True,
)
def installed_count(self):
return sum(1 for b in self.batteries if b.status == "installed")
@@ -70,6 +91,12 @@ class Battery(Base):
order_by="BatteryPctLog.recorded_at.desc()",
cascade="all, delete-orphan",
)
logbook_entries = relationship(
"Logbook", secondary=battery_logbook_table,
order_by="Logbook.recorded_at.desc()",
cascade="all, delete-orphan",
single_parent=True,
)
def is_available(self):
return self.status == "available"
@@ -127,3 +154,18 @@ class BatteryPctLog(Base):
def __repr__(self):
return f"<BatteryPctLog {self.battery_id} {self.recorded_at} {self.percentage}%>"
class Logbook(Base):
__tablename__ = "logbook"
id = Column(Integer, primary_key=True, autoincrement=True)
body = Column(Text, nullable=False)
recorded_at = Column(String(19), nullable=False) # "YYYY-MM-DD HH:MM:SS"
# Note: SQLite has no native DATETIME type (stores dates as text).
# On MariaDB this would be a native DATETIME column — swap String(19) for
# DateTime when migrating, since SQLAlchemy's DateTime maps to DATETIME there.
# String(19) is used here to match every other timestamp column in the codebase.
def __repr__(self):
return f"<Logbook {self.recorded_at}>"
+14
View File
@@ -334,6 +334,20 @@
{% block content %}{% endblock %}
</div>
<footer style="text-align:center;padding:1.25rem 1rem 1.5rem;margin-top:1rem;
border-top:1px solid var(--border);font-size:0.8rem;">
<a href="{{ url_for('export_page') }}"
style="color:var(--text-muted);text-decoration:none;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">Export data</a>
<span style="color:var(--text-muted);margin:0 0.5rem;">·</span>
<a href="{{ url_for('import_page') }}"
style="color:var(--text-muted);text-decoration:none;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">Import data</a>
</footer>
<div id="confirm-modal" role="dialog" aria-modal="true">
<div id="confirm-modal-box">
<p id="confirm-modal-msg"></p>
+32
View File
@@ -216,6 +216,38 @@
{% endif %}
</div>
<!-- Logbook -->
<div class="card">
<h2>Logbook</h2>
{% if logbook_entries %}
<div style="display:flex;flex-direction:column;gap:0.6rem;margin-bottom:1rem;">
{% for entry in logbook_entries %}
<div style="border-left:3px solid var(--border);padding:0.35rem 0.75rem;
display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;">
<div>
<div class="text-muted" style="font-size:0.78rem;margin-bottom:0.15rem;">{{ entry.recorded_at }}</div>
<div style="white-space:pre-wrap;">{{ entry.body }}</div>
</div>
<form method="post"
action="{{ url_for('battery_logbook_delete', battery_id=battery.id, entry_id=entry.id) }}"
data-confirm="Delete this logbook entry?" data-confirm-ok="Delete">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted" style="margin-bottom:0.75rem;">No logbook entries yet.</p>
{% endif %}
<h3 style="font-size:1rem;margin:0.75rem 0 0.5rem;color:var(--text-h2);">Add Entry</h3>
<form method="post" action="{{ url_for('battery_logbook_add', battery_id=battery.id) }}">
<div class="form-group" style="margin-bottom:0.5rem;">
<textarea name="body" placeholder="Write a note…" rows="2" required style="min-height:60px;"></textarea>
</div>
<button class="btn btn-primary" type="submit">Add Entry</button>
</form>
</div>
<!-- Edit Details -->
<div class="card">
<h2>Edit Details</h2>
+31
View File
@@ -221,6 +221,37 @@ function addInstallRow() {
{% endif %}
</div>
<div class="card">
<h2>Logbook</h2>
{% if logbook_entries %}
<div style="display:flex;flex-direction:column;gap:0.6rem;margin-bottom:1rem;">
{% for entry in logbook_entries %}
<div style="border-left:3px solid var(--border);padding:0.35rem 0.75rem;
display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;">
<div>
<div class="text-muted" style="font-size:0.78rem;margin-bottom:0.15rem;">{{ entry.recorded_at }}</div>
<div style="white-space:pre-wrap;">{{ entry.body }}</div>
</div>
<form method="post"
action="{{ url_for('device_logbook_delete', device_id=device.id, entry_id=entry.id) }}"
data-confirm="Delete this logbook entry?" data-confirm-ok="Delete">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted" style="margin-bottom:0.75rem;">No logbook entries yet.</p>
{% endif %}
<h3 style="font-size:1rem;margin:0.75rem 0 0.5rem;color:var(--text-h2);">Add Entry</h3>
<form method="post" action="{{ url_for('device_logbook_add', device_id=device.id) }}">
<div class="form-group" style="margin-bottom:0.5rem;">
<textarea name="body" placeholder="Write a note…" rows="2" required style="min-height:60px;"></textarea>
</div>
<button class="btn btn-primary" type="submit">Add Entry</button>
</form>
</div>
<div class="card">
<h2>Edit Device</h2>
<form method="post" action="{{ url_for('device_edit', device_id=device.id) }}">
+70
View File
@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}Export — Battery Tracker{% endblock %}
{% block content %}
<h1 style="margin-bottom:1.25rem;">Export Data</h1>
<div class="card" style="margin-bottom:1rem;">
<h2 style="margin-bottom:0.5rem;">Full Export</h2>
<p style="color:var(--text-muted,#6b7280);margin-bottom:1rem;">
Download your complete dataset — batteries, devices, charge logs, capacity tests, and percentage history.
</p>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
<a href="{{ url_for('export_csv_zip') }}" class="btn btn-primary">Download All CSVs (.zip)</a>
<a href="{{ url_for('export_json') }}" class="btn btn-secondary">Download JSON</a>
</div>
</div>
<div class="card">
<h2 style="margin-bottom:0.5rem;">Individual CSV Files</h2>
<p style="color:var(--text-muted,#6b7280);margin-bottom:1rem;">Download a single table at a time.</p>
<table style="border-collapse:collapse;width:100%;">
<tbody>
<tr>
<td style="padding:0.4rem 0.75rem 0.4rem 0;">
<a href="{{ url_for('export_batteries_csv') }}" class="btn btn-sm btn-secondary">batteries.csv</a>
</td>
<td style="padding:0.4rem 0;color:var(--text-muted,#6b7280);font-size:0.9rem;">
All batteries with device name
</td>
</tr>
<tr>
<td style="padding:0.4rem 0.75rem 0.4rem 0;">
<a href="{{ url_for('export_devices_csv') }}" class="btn btn-sm btn-secondary">devices.csv</a>
</td>
<td style="padding:0.4rem 0;color:var(--text-muted,#6b7280);font-size:0.9rem;">
All devices with installed battery count
</td>
</tr>
<tr>
<td style="padding:0.4rem 0.75rem 0.4rem 0;">
<a href="{{ url_for('export_charge_logs_csv') }}" class="btn btn-sm btn-secondary">charge-logs.csv</a>
</td>
<td style="padding:0.4rem 0;color:var(--text-muted,#6b7280);font-size:0.9rem;">
All charge log entries with battery label
</td>
</tr>
<tr>
<td style="padding:0.4rem 0.75rem 0.4rem 0;">
<a href="{{ url_for('export_capacity_tests_csv') }}" class="btn btn-sm btn-secondary">capacity-tests.csv</a>
</td>
<td style="padding:0.4rem 0;color:var(--text-muted,#6b7280);font-size:0.9rem;">
All capacity test results with battery label
</td>
</tr>
<tr>
<td style="padding:0.4rem 0.75rem 0.4rem 0;">
<a href="{{ url_for('export_pct_logs_csv') }}" class="btn btn-sm btn-secondary">pct-logs.csv</a>
</td>
<td style="padding:0.4rem 0;color:var(--text-muted,#6b7280);font-size:0.9rem;">
Battery percentage history
</td>
</tr>
</tbody>
</table>
</div>
<div style="margin-top:0.75rem;">
<a href="{{ url_for('import_page') }}" class="btn btn-secondary">Import JSON</a>
</div>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Import — Battery Tracker{% endblock %}
{% block content %}
<h1 style="margin-bottom:1.25rem;">Import Data</h1>
<div class="card" style="margin-bottom:1rem;">
<p style="color:var(--text-muted,#6b7280);margin-bottom:1rem;">
Upload a JSON file exported from Battery Tracker.
Devices are matched by name and batteries by label — existing records are skipped (not overwritten).
Charge logs, capacity tests, and percentage logs are always appended.
</p>
<form method="POST" action="{{ url_for('import_page') }}" enctype="multipart/form-data">
<div class="form-group">
<label for="file">JSON Export File</label>
<input type="file" id="file" name="file" accept=".json,application/json" required
style="width:100%;padding:0.45rem 0.65rem;border:1px solid var(--border-input,#d1d5db);
border-radius:4px;font-size:0.9rem;background:var(--bg-input,#fff);
color:var(--text-body,#111827);">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Import</button>
<a href="{{ url_for('export_page') }}" class="btn btn-secondary">Back to Export</a>
</div>
</form>
</div>
{% if results is not none %}
<div class="card">
<h2 style="margin-bottom:1rem;">Import Results</h2>
<table style="border-collapse:collapse;width:100%;max-width:480px;">
<thead>
<tr style="border-bottom:2px solid var(--border,#e5e7eb);">
<th style="text-align:left;padding:0.4rem 0.75rem 0.4rem 0;font-size:0.85rem;color:var(--text-muted,#6b7280);font-weight:600;">Category</th>
<th style="text-align:right;padding:0.4rem 0.75rem;font-size:0.85rem;color:var(--text-muted,#6b7280);font-weight:600;">Created / Appended</th>
<th style="text-align:right;padding:0.4rem 0;font-size:0.85rem;color:var(--text-muted,#6b7280);font-weight:600;">Skipped</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid var(--border,#e5e7eb);">
<td style="padding:0.45rem 0.75rem 0.45rem 0;">Devices</td>
<td style="text-align:right;padding:0.45rem 0.75rem;">{{ results.devices_created }}</td>
<td style="text-align:right;padding:0.45rem 0;">{{ results.devices_skipped }}</td>
</tr>
<tr style="border-bottom:1px solid var(--border,#e5e7eb);">
<td style="padding:0.45rem 0.75rem 0.45rem 0;">Batteries</td>
<td style="text-align:right;padding:0.45rem 0.75rem;">{{ results.batteries_created }}</td>
<td style="text-align:right;padding:0.45rem 0;">{{ results.batteries_skipped }}</td>
</tr>
<tr style="border-bottom:1px solid var(--border,#e5e7eb);">
<td style="padding:0.45rem 0.75rem 0.45rem 0;">Charge Logs</td>
<td style="text-align:right;padding:0.45rem 0.75rem;">{{ results.charge_logs_appended }}</td>
<td style="text-align:right;padding:0.45rem 0;">{{ results.charge_logs_skipped }}</td>
</tr>
<tr style="border-bottom:1px solid var(--border,#e5e7eb);">
<td style="padding:0.45rem 0.75rem 0.45rem 0;">Capacity Tests</td>
<td style="text-align:right;padding:0.45rem 0.75rem;">{{ results.capacity_tests_appended }}</td>
<td style="text-align:right;padding:0.45rem 0;">{{ results.capacity_tests_skipped }}</td>
</tr>
<tr>
<td style="padding:0.45rem 0.75rem 0.45rem 0;">% Logs</td>
<td style="text-align:right;padding:0.45rem 0.75rem;">{{ results.pct_logs_appended }}</td>
<td style="text-align:right;padding:0.45rem 0;">{{ results.pct_logs_skipped }}</td>
</tr>
</tbody>
</table>
<div style="margin-top:1.25rem;display:flex;gap:0.75rem;flex-wrap:wrap;">
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
<a href="{{ url_for('import_page') }}" class="btn btn-secondary">Import Another File</a>
</div>
</div>
{% endif %}
{% endblock %}
+204 -1
View File
@@ -603,4 +603,207 @@ def test_bulk_install_filters_by_size(client):
data={"brand[]": "Energizer", "qty[]": "1"},
follow_redirects=True)
assert resp.status_code == 200
assert b"only 0 available" in resp.data
# ------------------------------------------------------------------ #
# 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