Three features: device dropdown filter, charge log history, unassign-all

- Device dropdowns (quick-assign, bulk install, assign page) now only show
  devices with free slots; full devices are excluded entirely
- New ChargeLog model tracks charge dates with optional cycle increment;
  battery detail page gets a Charge History card with add/delete rows
- Device list page gets per-device Unassign All button (with confirmation)
  via new POST /device/<id>/unassign-all route
This commit is contained in:
2026-04-13 08:12:23 -05:00
parent 6597fcd4ac
commit b1bc02e963
6 changed files with 167 additions and 25 deletions
+72 -6
View File
@@ -2,7 +2,7 @@ from flask import Flask, render_template, redirect, url_for, request, flash, abo
from sqlalchemy import create_engine, func
from sqlalchemy.orm import scoped_session, sessionmaker
from models import Base, Battery, Device, CapacityTest
from models import Base, Battery, Device, CapacityTest, ChargeLog
def create_app(config_object="config"):
@@ -39,8 +39,10 @@ def create_app(config_object="config"):
.distinct().order_by(Battery.storage_location).all()
]
devices = db.query(Device).order_by(Device.name).all()
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots]
return render_template("dashboard.html", batteries=batteries,
storage_locations=storage_locations, devices=devices)
storage_locations=storage_locations, devices=devices,
devices_with_slots=devices_with_slots)
# ------------------------------------------------------------------ #
# Battery — add
@@ -114,9 +116,14 @@ def create_app(config_object="config"):
.filter_by(battery_id=battery_id)
.order_by(CapacityTest.tested_date, CapacityTest.id)
.all())
charge_logs = (db.query(ChargeLog)
.filter_by(battery_id=battery_id)
.order_by(ChargeLog.charged_date.desc(), ChargeLog.id.desc())
.all())
return render_template("battery_detail.html", battery=battery,
storage_locations=storage_locations,
capacity_tests=capacity_tests)
capacity_tests=capacity_tests,
charge_logs=charge_logs)
# ------------------------------------------------------------------ #
# Battery — edit notes
@@ -199,6 +206,42 @@ def create_app(config_object="config"):
flash("Test record deleted.", "success")
return redirect(url_for("battery_detail", battery_id=battery_id))
# ------------------------------------------------------------------ #
# Battery — charge log history
# ------------------------------------------------------------------ #
@app.route("/battery/<int:battery_id>/charge-log/add", methods=["POST"])
def battery_charge_log_add(battery_id):
battery = db.get(Battery, battery_id)
if battery is None:
abort(404)
date_val = request.form.get("charged_date", "").strip()
if not date_val:
flash("Date is required.", "error")
return redirect(url_for("battery_detail", battery_id=battery_id))
increment = 1 if request.form.get("increment_cycles") else 0
notes = request.form.get("notes", "").strip() or None
if increment:
battery.charge_cycles = (battery.charge_cycles or 0) + 1
db.add(ChargeLog(battery_id=battery_id, charged_date=date_val,
increment_cycles=increment, notes=notes))
db.commit()
flash("Charge log entry added.", "success")
return redirect(url_for("battery_detail", battery_id=battery_id))
@app.route("/battery/<int:battery_id>/charge-log/<int:log_id>/delete", methods=["POST"])
def battery_charge_log_delete(battery_id, log_id):
battery = db.get(Battery, battery_id)
log = db.get(ChargeLog, log_id)
if battery is None or log is None or log.battery_id != battery_id:
abort(404)
if log.increment_cycles:
battery.charge_cycles = max(0, (battery.charge_cycles or 0) - 1)
db.delete(log)
db.commit()
flash("Charge log entry deleted.", "success")
return redirect(url_for("battery_detail", battery_id=battery_id))
# ------------------------------------------------------------------ #
# Battery — assign
# ------------------------------------------------------------------ #
@@ -214,13 +257,14 @@ def create_app(config_object="config"):
return redirect(url_for("battery_detail", battery_id=battery_id))
devices = db.query(Device).order_by(Device.name).all()
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots]
if request.method == "POST":
device_id = request.form.get("device_id", type=int)
device = db.get(Device, device_id)
if device is None:
flash("Device not found.", "error")
return render_template("assign.html", battery=battery, devices=devices)
return render_template("assign.html", battery=battery, devices=devices_with_slots)
if device.installed_count() >= device.battery_slots:
flash(
@@ -228,7 +272,7 @@ def create_app(config_object="config"):
f"({device.battery_slots}/{device.battery_slots} slots used).",
"error",
)
return render_template("assign.html", battery=battery, devices=devices)
return render_template("assign.html", battery=battery, devices=devices_with_slots)
# Warn on brand mix (non-blocking)
existing_brands = device.installed_brands()
@@ -249,7 +293,7 @@ def create_app(config_object="config"):
flash(f"{battery.label} assigned to {device.name}.", "success")
return redirect(url_for("dashboard"))
return render_template("assign.html", battery=battery, devices=devices)
return render_template("assign.html", battery=battery, devices=devices_with_slots)
# ------------------------------------------------------------------ #
# Battery — unassign
@@ -668,6 +712,28 @@ def create_app(config_object="config"):
flash(f"Device '{name}' deleted. All batteries marked available.", "success")
return redirect(url_for("device_list"))
# ------------------------------------------------------------------ #
# Devices — unassign all batteries
# ------------------------------------------------------------------ #
@app.route("/device/<int:device_id>/unassign-all", methods=["POST"])
def device_unassign_all(device_id):
device = db.get(Device, device_id)
if device is None:
abort(404)
count = 0
for battery in device.batteries:
if battery.status == "installed":
battery.status = "available"
battery.device_id = None
count += 1
db.commit()
flash(
f"Unassigned {count} batter{'y' if count == 1 else 'ies'} from {device.name}.",
"success",
)
return redirect(url_for("device_list"))
return app
+20
View File
@@ -54,6 +54,11 @@ class Battery(Base):
order_by="CapacityTest.tested_date",
cascade="all, delete-orphan",
)
charge_logs = relationship(
"ChargeLog", back_populates="battery",
order_by="ChargeLog.charged_date",
cascade="all, delete-orphan",
)
def is_available(self):
return self.status == "available"
@@ -81,3 +86,18 @@ class CapacityTest(Base):
def __repr__(self):
return f"<CapacityTest {self.battery_id} {self.tested_date} {self.tested_capacity_mah}mAh>"
class ChargeLog(Base):
__tablename__ = "charge_log"
id = Column(Integer, primary_key=True, autoincrement=True)
battery_id = Column(Integer, ForeignKey("battery.id", ondelete="CASCADE"), nullable=False)
charged_date = Column(String(10), nullable=False) # YYYY-MM-DD
increment_cycles = Column(Integer, nullable=False, default=0) # 0 or 1
notes = Column(Text, nullable=True)
battery = relationship("Battery", back_populates="charge_logs")
def __repr__(self):
return f"<ChargeLog {self.battery_id} {self.charged_date}>"
+6 -13
View File
@@ -11,23 +11,16 @@
<div class="form-group">
<label>Select Device</label>
{% for device in devices %}
{% set full = device.installed_count() >= device.battery_slots %}
{% set mix = device.installed_brands() and battery.brand not in device.installed_brands() %}
<div class="device-option" style="margin-bottom:0.75rem;padding:0.75rem;border:1px solid #e2e8f0;border-radius:4px;
{% if full %}opacity:0.5;{% endif %}background:#fff;">
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;min-height:44px;cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
<input type="radio" name="device_id" value="{{ device.id }}"
{% if full %}disabled{% endif %}
style="cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
{% set mix = device.installed_brands() and battery.brand not in device.installed_brands() %}
<div class="device-option" style="margin-bottom:0.75rem;padding:0.75rem;border:1px solid #e2e8f0;border-radius:4px;background:#fff;">
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;min-height:44px;cursor:pointer;">
<input type="radio" name="device_id" value="{{ device.id }}" style="cursor:pointer;">
<span>
<strong>{{ device.name }}</strong>
<span class="text-muted">({{ device.installed_count() }}/{{ device.battery_slots }} slots used)</span>
{% if full %}
<span class="badge badge-retired">Full</span>
{% endif %}
</span>
</label>
{% if mix and not full %}
{% if mix %}
<p class="text-warning" style="margin-top:0.3rem;margin-left:1.6rem;">
⚠ Already has {{ device.installed_brands()|join(', ') }} — mixing brands not recommended.
</p>
@@ -42,7 +35,7 @@
</div>
</form>
{% else %}
<p class="text-muted">No devices exist yet. <a href="{{ url_for('device_add') }}">Add a device first.</a></p>
<p class="text-muted">No devices with free slots. <a href="{{ url_for('device_add') }}">Add a device</a> or free up slots first.</p>
{% endif %}
</div>
{% endblock %}
+60
View File
@@ -148,6 +148,66 @@
</form>
</div>
<!-- Charge History -->
<div class="card">
<h2>Charge History</h2>
{% if charge_logs %}
<div class="table-wrap">
<table class="responsive-table">
<thead>
<tr>
<th>Date</th>
<th>+Cycle</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
{% for log in charge_logs %}
<tr>
<td data-label="Date">{{ log.charged_date }}</td>
<td data-label="+Cycle">{{ '✓' if log.increment_cycles else '—' }}</td>
<td data-label="Notes" class="text-muted">{{ log.notes or '—' }}</td>
<td data-label="">
<form class="inline" method="post"
action="{{ url_for('battery_charge_log_delete', battery_id=battery.id, log_id=log.id) }}"
onsubmit="return confirm('Delete this charge log entry?')">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted" style="margin-bottom:0.75rem;">No charge log entries yet.</p>
{% endif %}
<h3 style="font-size:1rem;margin:1rem 0 0.5rem;color:var(--text-h2);">Add Charge Entry</h3>
<form method="post" action="{{ url_for('battery_charge_log_add', battery_id=battery.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. trickle charge overnight">
</div>
<div style="padding-bottom:1rem;">
<button class="btn btn-primary" type="submit">Add</button>
</div>
</form>
</div>
<!-- Edit Details -->
<div class="card">
<h2>Edit Details</h2>
+3 -6
View File
@@ -117,7 +117,7 @@
<select id="bulk-device-select" name="device_id"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">— select device —</option>
{% for d in devices %}
{% for d in devices_with_slots %}
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
{% endfor %}
</select>
@@ -181,11 +181,8 @@
<select id="qas-{{ b.id }}"
style="padding:0.2rem 0.3rem;font-size:0.8rem;border:1px solid #cbd5e1;border-radius:4px;max-width:110px;vertical-align:middle;">
<option value="">— assign —</option>
{% for d in devices %}
<option value="{{ d.id }}"
{% if d.installed_count() >= d.battery_slots %}disabled{% endif %}>
{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})
</option>
{% for d in devices_with_slots %}
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
{% endfor %}
</select>
<button type="button" class="btn btn-sm btn-primary"
+6
View File
@@ -57,6 +57,12 @@
</td>
<td data-label="Actions" style="white-space:nowrap;">
<a class="btn btn-sm btn-secondary" href="{{ url_for('device_detail', device_id=d.id) }}">View</a>
{% if d.installed_count() > 0 %}
<form class="inline" method="post" action="{{ url_for('device_unassign_all', device_id=d.id) }}"
onsubmit="return confirm('Unassign all batteries from {{ d.name }}?')">
<button class="btn btn-sm btn-warning" type="submit">Unassign All</button>
</form>
{% endif %}
<form class="inline" method="post" action="{{ url_for('device_delete', device_id=d.id) }}"
onsubmit="return confirm('Delete {{ d.name }}? All installed batteries will be unassigned.');">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>