Add device charge-all route, last-charged column, and CSRF fix for quick-assign

This commit is contained in:
2026-06-05 20:08:19 -05:00
parent de986a9305
commit 55ce963d79
4 changed files with 135 additions and 19 deletions
+51 -15
View File
@@ -23,6 +23,25 @@ def _parse_date(val: str) -> str | None:
return None return None
def _record_charge(db, battery, date_val, increment, notes):
"""Apply one charge event to battery. Caller must call db.commit()."""
if increment:
battery.charge_cycles = (battery.charge_cycles or 0) + 1
battery.battery_percentage = 100
db.add(BatteryPctLog(
battery_id=battery.id,
percentage=100,
recorded_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
source="charge",
))
db.add(ChargeLog(
battery_id=battery.id,
charged_date=date_val,
increment_cycles=increment,
notes=notes,
))
def create_app(config_object="config"): def create_app(config_object="config"):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config_object) app.config.from_object(config_object)
@@ -345,17 +364,7 @@ def create_app(config_object="config"):
return redirect(url_for("battery_detail", battery_id=battery_id)) return redirect(url_for("battery_detail", battery_id=battery_id))
increment = 1 if request.form.get("increment_cycles") else 0 increment = 1 if request.form.get("increment_cycles") else 0
notes = request.form.get("notes", "").strip() or None notes = request.form.get("notes", "").strip() or None
if increment: _record_charge(db, battery, date_val, increment, notes)
battery.charge_cycles = (battery.charge_cycles or 0) + 1
battery.battery_percentage = 100
db.add(BatteryPctLog(
battery_id=battery_id,
percentage=100,
recorded_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
source="charge",
))
db.add(ChargeLog(battery_id=battery_id, charged_date=date_val,
increment_cycles=increment, notes=notes))
db.commit() db.commit()
flash("Charge log entry added.", "success") flash("Charge log entry added.", "success")
return redirect(url_for("battery_detail", battery_id=battery_id)) return redirect(url_for("battery_detail", battery_id=battery_id))
@@ -608,10 +617,7 @@ def create_app(config_object="config"):
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
increment = 1 if request.form.get("increment_cycles") else 0 increment = 1 if request.form.get("increment_cycles") else 0
for b in batteries: for b in batteries:
db.add(ChargeLog(battery_id=b.id, charged_date=date_val, _record_charge(db, b, date_val, increment, notes=None)
increment_cycles=increment, notes=None))
if increment:
b.charge_cycles = (b.charge_cycles or 0) + 1
db.commit() db.commit()
flash( flash(
f"Logged charge date {date_val} for " f"Logged charge date {date_val} for "
@@ -997,6 +1003,36 @@ def create_app(config_object="config"):
flash("No batteries were installed.", "warning") flash("No batteries were installed.", "warning")
return redirect(url_for("device_detail", device_id=device_id)) return redirect(url_for("device_detail", device_id=device_id))
# ------------------------------------------------------------------ #
# Devices — charge all installed batteries
# ------------------------------------------------------------------ #
@app.route("/device/<int:device_id>/charge-all", methods=["POST"])
def device_charge_all(device_id):
device = db.get(Device, device_id)
if device is None:
abort(404)
date_val = _parse_date(request.form.get("charged_date", "").strip())
if not date_val:
flash("A valid date (YYYY-MM-DD) is required.", "error")
return redirect(url_for("device_detail", device_id=device_id))
increment = 1 if request.form.get("increment_cycles") else 0
notes = request.form.get("notes", "").strip() or None
installed = [b for b in device.batteries if b.status == "installed"]
if not installed:
flash("No installed batteries to log.", "warning")
return redirect(url_for("device_detail", device_id=device_id))
for battery in installed:
_record_charge(db, battery, date_val, increment, notes)
db.commit()
n = len(installed)
flash(
f"Logged charge for {n} batter{'y' if n == 1 else 'ies'} in {device.name}"
+ (" (+cycles)." if increment else "."),
"success",
)
return redirect(url_for("device_detail", device_id=device_id))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Export # Export
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
+7 -3
View File
@@ -324,6 +324,7 @@
</div> </div>
<script> <script>
var CSRF_TOKEN = '{{ csrf_token() }}';
var selectAll = document.getElementById('select-all'); var selectAll = document.getElementById('select-all');
var toolbar = document.getElementById('bulk-toolbar'); var toolbar = document.getElementById('bulk-toolbar');
var countEl = document.getElementById('selected-count'); var countEl = document.getElementById('selected-count');
@@ -471,9 +472,12 @@ function quickAssign(action, batteryId) {
if (!sel.value) { sel.focus(); return; } if (!sel.value) { sel.focus(); return; }
var f = document.createElement('form'); var f = document.createElement('form');
f.method = 'post'; f.action = action; f.method = 'post'; f.action = action;
var inp = document.createElement('input'); var deviceInp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'device_id'; inp.value = sel.value; deviceInp.type = 'hidden'; deviceInp.name = 'device_id'; deviceInp.value = sel.value;
f.appendChild(inp); f.appendChild(deviceInp);
var csrfInp = document.createElement('input');
csrfInp.type = 'hidden'; csrfInp.name = 'csrf_token'; csrfInp.value = CSRF_TOKEN;
f.appendChild(csrfInp);
document.body.appendChild(f); document.body.appendChild(f);
f.submit(); f.submit();
} }
+28 -1
View File
@@ -166,7 +166,7 @@ function addInstallRow() {
<div class="table-wrap"> <div class="table-wrap">
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
<tr><th>Label</th><th>Brand</th>{% if ha_enabled %}<th>Bat %</th>{% endif %}<th>Notes</th><th>Actions</th></tr> <tr><th>Label</th><th>Brand</th>{% if ha_enabled %}<th>Bat %</th>{% endif %}<th>Last Charged</th><th>Notes</th><th>Actions</th></tr>
</thead> </thead>
<tbody> <tbody>
{% for b in installed %} {% for b in installed %}
@@ -182,6 +182,7 @@ function addInstallRow() {
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </td>
{% endif %} {% endif %}
<td data-label="Last Charged" class="text-muted">{{ b.charge_logs[-1].charged_date if b.charge_logs else '—' }}</td>
<td data-label="Notes" class="text-muted">{{ b.notes or '—' }}</td> <td data-label="Notes" class="text-muted">{{ b.notes or '—' }}</td>
<td data-label="Actions"> <td data-label="Actions">
<form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}"> <form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}">
@@ -209,6 +210,32 @@ function addInstallRow() {
{% endif %} {% endif %}
</div> </div>
{% if installed %}
<div class="card">
<h2>Charge All Installed Batteries</h2>
<form method="post" action="{{ url_for('device_charge_all', device_id=device.id) }}"
style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
<div class="form-group" style="margin:0;flex:1;min-width:140px;">
<label>Date</label>
<input type="date" name="charged_date" required>
</div>
<div class="form-group" style="margin:0;align-self:flex-end;padding-bottom:1rem;">
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:normal;cursor:pointer;">
<input type="checkbox" name="increment_cycles" value="1" checked>
Increment charge cycles
</label>
</div>
<div class="form-group" style="margin:0;flex:2;min-width:160px;">
<label>Notes (optional)</label>
<input type="text" name="notes" placeholder="e.g. overnight charge">
</div>
<div style="padding-bottom:1rem;">
<button class="btn btn-primary" type="submit">Log Charge for All</button>
</div>
</form>
</div>
{% endif %}
<div class="card"> <div class="card">
<h2>Install Specific Batteries{% if device.battery_size %} <small class="text-muted" style="font-weight:normal;font-size:0.8rem;">({{ device.battery_size }} only)</small>{% endif %}</h2> <h2>Install Specific Batteries{% 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 %}
+49
View File
@@ -376,6 +376,29 @@ def test_dashboard_quick_assign_full_device_blocked(client):
assert b"full" in resp.data.lower() assert b"full" in resp.data.lower()
def test_quick_assign_reassign_battery(client):
"""Quick-assign moves a battery that is already installed in another device."""
client.post("/device/add", data={"name": "Box A", "battery_slots": "2", "battery_size": "AA"})
client.post("/device/add", data={"name": "Box B", "battery_slots": "2", "battery_size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
client.post("/battery/1/assign", data={"device_id": "1"}) # install into Box A
resp = client.post("/battery/1/assign", data={"device_id": "2"},
follow_redirects=True)
assert resp.status_code == 200
assert b"Box B" in resp.data
def test_quick_assign_retired_battery_blocked(client):
"""battery_assign POST for a retired battery is blocked."""
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/1/retire")
resp = client.post("/battery/1/assign", data={"device_id": "1"},
follow_redirects=True)
assert resp.status_code == 200
assert b"retired" in resp.data.lower()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Device — install-one (specific battery) # Device — install-one (specific battery)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -836,3 +859,29 @@ def test_device_install_batch(seeded_client):
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Installed 2" in resp.data assert b"Installed 2" in resp.data
assert b"2 / 2" in resp.data assert b"2 / 2" in resp.data
def test_device_charge_all(seeded_client):
client = seeded_client
# install battery 1 into device 1
client.post("/device/1/install-one", data={"battery_id": "1"})
resp = client.post(
"/device/1/charge-all",
data={"charged_date": "2025-01-01", "increment_cycles": "1"},
follow_redirects=True,
)
assert resp.status_code == 200
assert b"Logged charge for 1 battery" in resp.data
assert b"+cycles" in resp.data
def test_device_charge_all_no_installed(seeded_client):
client = seeded_client
# device 1 has no batteries installed yet
resp = client.post(
"/device/1/charge-all",
data={"charged_date": "2025-01-01"},
follow_redirects=True,
)
assert resp.status_code == 200
assert b"No installed batteries" in resp.data