Add optional battery metadata fields

New nullable columns on Battery: size, chemistry, capacity_mah,
tested_capacity_mah, tested_date, charge_cycles, purchase_date.

Battery detail page shows all populated fields and a full edit form
with select dropdowns for size and chemistry (with Other fallback).
Capacity health % shown in green/orange/red when both nominal and
tested capacity are set. Dashboard gains a Size column.
This commit is contained in:
2026-04-12 14:57:22 -05:00
parent 47e1059532
commit 604d7bb699
5 changed files with 163 additions and 12 deletions
+21 -4
View File
@@ -77,14 +77,31 @@ def create_app(config_object="config"):
# Battery — edit notes
# ------------------------------------------------------------------ #
@app.route("/battery/<int:battery_id>/edit-notes", methods=["POST"])
def battery_edit_notes(battery_id):
@app.route("/battery/<int:battery_id>/edit-details", methods=["POST"])
def battery_edit_details(battery_id):
battery = db.get(Battery, battery_id)
if battery is None:
abort(404)
battery.notes = request.form.get("notes", "").strip() or None
f = request.form
def _int(key):
v = f.get(key, "").strip()
try:
return int(v) if v else None
except ValueError:
return None
battery.notes = f.get("notes", "").strip() or None
battery.size = f.get("size", "").strip() or None
battery.chemistry = f.get("chemistry", "").strip() or None
battery.capacity_mah = _int("capacity_mah")
battery.tested_capacity_mah = _int("tested_capacity_mah")
battery.tested_date = f.get("tested_date", "").strip() or None
battery.charge_cycles = _int("charge_cycles")
battery.purchase_date = f.get("purchase_date", "").strip() or None
db.commit()
flash("Notes updated.", "success")
flash("Details updated.", "success")
return redirect(url_for("battery_detail", battery_id=battery_id))
# ------------------------------------------------------------------ #
+9
View File
@@ -37,6 +37,15 @@ class Battery(Base):
device_id = Column(Integer, ForeignKey("device.id", ondelete="SET NULL"), nullable=True)
notes = Column(Text, nullable=True)
# Optional metadata
size = Column(String(20), nullable=True) # AA, AAA, 18650, CR2032 …
chemistry = Column(String(20), nullable=True) # NiMH, Alkaline, Li-ion …
capacity_mah = Column(Integer, nullable=True) # nominal capacity in mAh
tested_capacity_mah = Column(Integer, nullable=True) # last measured capacity in mAh
tested_date = Column(String(10), nullable=True) # YYYY-MM-DD of last test
charge_cycles = Column(Integer, nullable=True) # number of charge cycles
purchase_date = Column(String(10), nullable=True) # YYYY-MM-DD when purchased
device = relationship("Device", back_populates="batteries")
def is_available(self):
+129 -6
View File
@@ -4,6 +4,15 @@
{% block content %}
<h1>{{ battery.label }}</h1>
{% macro meta_row(label, value) %}
{% if value %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">{{ label }}</td>
<td style="border:none;">{{ value }}</td>
</tr>
{% endif %}
{% endmacro %}
<div class="card">
<table style="width:auto;border:none;">
<tr>
@@ -28,17 +37,117 @@
{% endif %}
</td>
</tr>
{{ meta_row("Size", battery.size) }}
{{ meta_row("Chemistry", battery.chemistry) }}
{% if battery.capacity_mah %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Capacity</td>
<td style="border:none;">
{{ battery.capacity_mah }} mAh
{% if battery.tested_capacity_mah %}
{% set pct = (battery.tested_capacity_mah / battery.capacity_mah * 100)|round|int %}
{% if pct >= 80 %}{% set health_color = "#166534" %}
{% elif pct >= 60 %}{% set health_color = "#92400e" %}
{% else %}{% set health_color = "#991b1b" %}
{% endif %}
&rarr; tested <strong style="color:{{ health_color }};">{{ battery.tested_capacity_mah }} mAh ({{ pct }}%)</strong>
{% if battery.tested_date %}<span class="text-muted">on {{ battery.tested_date }}</span>{% endif %}
{% endif %}
</td>
</tr>
{% elif battery.tested_capacity_mah %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Tested Capacity</td>
<td style="border:none;">
{{ battery.tested_capacity_mah }} mAh
{% if battery.tested_date %}<span class="text-muted">on {{ battery.tested_date }}</span>{% endif %}
</td>
</tr>
{% endif %}
{{ meta_row("Charge Cycles", battery.charge_cycles) }}
{{ meta_row("Purchase Date", battery.purchase_date) }}
{% if battery.notes %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
<td style="border:none;">{{ battery.notes }}</td>
</tr>
{% endif %}
</table>
</div>
<!-- Notes -->
<!-- Edit Details -->
<div class="card">
<h2>Notes</h2>
<form method="post" action="{{ url_for('battery_edit_notes', battery_id=battery.id) }}">
<div class="form-group">
<textarea name="notes" placeholder="No notes yet…">{{ battery.notes or '' }}</textarea>
<h2>Edit Details</h2>
<form method="post" action="{{ url_for('battery_edit_details', battery_id=battery.id) }}">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 1rem;">
<div class="form-group">
<label>Size</label>
<select id="size-select" onchange="metaSelectChanged(this,'size')">
<option value="">— none —</option>
{% for opt in ['AA','AAA','C','D','9V','18650','21700','14500','26650','CR2032','CR123A'] %}
<option value="{{ opt }}" {% if battery.size == opt %}selected{% endif %}>{{ opt }}</option>
{% endfor %}
<option value="__new__" {% if battery.size and battery.size not in ['AA','AAA','C','D','9V','18650','21700','14500','26650','CR2032','CR123A'] %}selected{% endif %}>Other…</option>
</select>
<input type="text" id="size" name="size" value="{{ battery.size or '' }}"
placeholder="Enter size"
style="display:{% if battery.size and battery.size not in ['AA','AAA','C','D','9V','18650','21700','14500','26650','CR2032','CR123A'] %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div>
<div class="form-group">
<label>Chemistry</label>
<select id="chemistry-select" onchange="metaSelectChanged(this,'chemistry')">
<option value="">— none —</option>
{% for opt in ['NiMH','Alkaline','Li-ion','LiFePO4','NiCd','Zinc-Carbon','Li-MnO2'] %}
<option value="{{ opt }}" {% if battery.chemistry == opt %}selected{% endif %}>{{ opt }}</option>
{% endfor %}
<option value="__new__" {% if battery.chemistry and battery.chemistry not in ['NiMH','Alkaline','Li-ion','LiFePO4','NiCd','Zinc-Carbon','Li-MnO2'] %}selected{% endif %}>Other…</option>
</select>
<input type="text" id="chemistry" name="chemistry" value="{{ battery.chemistry or '' }}"
placeholder="Enter chemistry"
style="display:{% if battery.chemistry and battery.chemistry not in ['NiMH','Alkaline','Li-ion','LiFePO4','NiCd','Zinc-Carbon','Li-MnO2'] %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div>
<div class="form-group">
<label for="capacity_mah">Capacity (mAh)</label>
<input type="number" id="capacity_mah" name="capacity_mah" min="0"
value="{{ battery.capacity_mah or '' }}" placeholder="e.g. 2000">
</div>
<div class="form-group">
<label for="charge_cycles">Charge Cycles</label>
<input type="number" id="charge_cycles" name="charge_cycles" min="0"
value="{{ battery.charge_cycles or '' }}" placeholder="e.g. 50">
</div>
<div class="form-group">
<label for="tested_capacity_mah">Tested Capacity (mAh)</label>
<input type="number" id="tested_capacity_mah" name="tested_capacity_mah" min="0"
value="{{ battery.tested_capacity_mah or '' }}" placeholder="e.g. 1850">
</div>
<div class="form-group">
<label for="tested_date">Test Date</label>
<input type="date" id="tested_date" name="tested_date"
value="{{ battery.tested_date or '' }}">
</div>
<div class="form-group">
<label for="purchase_date">Purchase Date</label>
<input type="date" id="purchase_date" name="purchase_date"
value="{{ battery.purchase_date or '' }}">
</div>
</div>
<button class="btn btn-primary" type="submit">Save Notes</button>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" placeholder="No notes yet…">{{ battery.notes or '' }}</textarea>
</div>
<button class="btn btn-primary" type="submit">Save Details</button>
</form>
</div>
@@ -67,4 +176,18 @@
</div>
<a class="text-muted" href="{{ url_for('dashboard') }}">&larr; Back to Dashboard</a>
<script>
function metaSelectChanged(sel, inputId) {
var input = document.getElementById(inputId);
if (sel.value === '__new__') {
input.style.display = '';
input.value = '';
input.focus();
} else {
input.style.display = 'none';
input.value = sel.value;
}
}
</script>
{% endblock %}
+3 -1
View File
@@ -51,6 +51,7 @@
<th style="width:1.5rem;"><input type="checkbox" id="select-all" title="Select all"></th>
<th>Label</th>
<th>Brand</th>
<th>Size</th>
<th>Status</th>
<th>Assigned To</th>
<th>Actions</th>
@@ -62,6 +63,7 @@
<td><input type="checkbox" name="battery_ids" value="{{ b.id }}" class="row-cb"></td>
<td><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
<td>{{ b.brand }}</td>
<td>{{ b.size or '—' }}</td>
<td>
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
</td>
@@ -94,7 +96,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-muted" style="text-align:center;padding:1rem;">No batteries found. <a href="{{ url_for('battery_add') }}">Add some.</a></td></tr>
<tr><td colspan="7" class="text-muted" style="text-align:center;padding:1rem;">No batteries found. <a href="{{ url_for('battery_add') }}">Add some.</a></td></tr>
{% endfor %}
</tbody>
</table>
+1 -1
View File
@@ -90,7 +90,7 @@ def test_battery_detail_not_found(client):
# ------------------------------------------------------------------ #
def test_edit_notes(seeded_client):
seeded_client.post("/battery/1/edit-notes", data={"notes": "test note here"})
seeded_client.post("/battery/1/edit-details", data={"notes": "test note here"})
resp = seeded_client.get("/battery/1")
assert b"test note here" in resp.data