Add device charge-all route, last-charged column, and CSRF fix for quick-assign
This commit is contained in:
@@ -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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user