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 import create_engine, func
from sqlalchemy.orm import scoped_session, sessionmaker 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"): def create_app(config_object="config"):
@@ -39,8 +39,10 @@ def create_app(config_object="config"):
.distinct().order_by(Battery.storage_location).all() .distinct().order_by(Battery.storage_location).all()
] ]
devices = db.query(Device).order_by(Device.name).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, 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 # Battery — add
@@ -114,9 +116,14 @@ def create_app(config_object="config"):
.filter_by(battery_id=battery_id) .filter_by(battery_id=battery_id)
.order_by(CapacityTest.tested_date, CapacityTest.id) .order_by(CapacityTest.tested_date, CapacityTest.id)
.all()) .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, return render_template("battery_detail.html", battery=battery,
storage_locations=storage_locations, storage_locations=storage_locations,
capacity_tests=capacity_tests) capacity_tests=capacity_tests,
charge_logs=charge_logs)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — edit notes # Battery — edit notes
@@ -199,6 +206,42 @@ def create_app(config_object="config"):
flash("Test record deleted.", "success") flash("Test record deleted.", "success")
return redirect(url_for("battery_detail", battery_id=battery_id)) 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 # Battery — assign
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -214,13 +257,14 @@ 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))
devices = db.query(Device).order_by(Device.name).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]
if request.method == "POST": if request.method == "POST":
device_id = request.form.get("device_id", type=int) device_id = request.form.get("device_id", type=int)
device = db.get(Device, device_id) device = db.get(Device, device_id)
if device is None: if device is None:
flash("Device not found.", "error") 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: if device.installed_count() >= device.battery_slots:
flash( flash(
@@ -228,7 +272,7 @@ def create_app(config_object="config"):
f"({device.battery_slots}/{device.battery_slots} slots used).", f"({device.battery_slots}/{device.battery_slots} slots used).",
"error", "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) # Warn on brand mix (non-blocking)
existing_brands = device.installed_brands() existing_brands = device.installed_brands()
@@ -249,7 +293,7 @@ def create_app(config_object="config"):
flash(f"{battery.label} assigned to {device.name}.", "success") flash(f"{battery.label} assigned to {device.name}.", "success")
return redirect(url_for("dashboard")) 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 # Battery — unassign
@@ -668,6 +712,28 @@ def create_app(config_object="config"):
flash(f"Device '{name}' deleted. All batteries marked available.", "success") flash(f"Device '{name}' deleted. All batteries marked available.", "success")
return redirect(url_for("device_list")) 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 return app
+20
View File
@@ -54,6 +54,11 @@ class Battery(Base):
order_by="CapacityTest.tested_date", order_by="CapacityTest.tested_date",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
charge_logs = relationship(
"ChargeLog", back_populates="battery",
order_by="ChargeLog.charged_date",
cascade="all, delete-orphan",
)
def is_available(self): def is_available(self):
return self.status == "available" return self.status == "available"
@@ -81,3 +86,18 @@ class CapacityTest(Base):
def __repr__(self): def __repr__(self):
return f"<CapacityTest {self.battery_id} {self.tested_date} {self.tested_capacity_mah}mAh>" 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}>"
+5 -12
View File
@@ -11,23 +11,16 @@
<div class="form-group"> <div class="form-group">
<label>Select Device</label> <label>Select Device</label>
{% for device in devices %} {% 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() %} {% 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; <div class="device-option" style="margin-bottom:0.75rem;padding:0.75rem;border:1px solid #e2e8f0;border-radius:4px;background:#fff;">
{% 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:pointer;">
<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 }}" style="cursor:pointer;">
<input type="radio" name="device_id" value="{{ device.id }}"
{% if full %}disabled{% endif %}
style="cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
<span> <span>
<strong>{{ device.name }}</strong> <strong>{{ device.name }}</strong>
<span class="text-muted">({{ device.installed_count() }}/{{ device.battery_slots }} slots used)</span> <span class="text-muted">({{ device.installed_count() }}/{{ device.battery_slots }} slots used)</span>
{% if full %}
<span class="badge badge-retired">Full</span>
{% endif %}
</span> </span>
</label> </label>
{% if mix and not full %} {% if mix %}
<p class="text-warning" style="margin-top:0.3rem;margin-left:1.6rem;"> <p class="text-warning" style="margin-top:0.3rem;margin-left:1.6rem;">
⚠ Already has {{ device.installed_brands()|join(', ') }} — mixing brands not recommended. ⚠ Already has {{ device.installed_brands()|join(', ') }} — mixing brands not recommended.
</p> </p>
@@ -42,7 +35,7 @@
</div> </div>
</form> </form>
{% else %} {% 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 %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
+60
View File
@@ -148,6 +148,66 @@
</form> </form>
</div> </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 --> <!-- Edit Details -->
<div class="card"> <div class="card">
<h2>Edit Details</h2> <h2>Edit Details</h2>
+3 -6
View File
@@ -117,7 +117,7 @@
<select id="bulk-device-select" name="device_id" <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;"> style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">— select device —</option> <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> <option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
{% endfor %} {% endfor %}
</select> </select>
@@ -181,11 +181,8 @@
<select id="qas-{{ b.id }}" <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;"> 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> <option value="">— assign —</option>
{% for d in devices %} {% for d in devices_with_slots %}
<option value="{{ d.id }}" <option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
{% if d.installed_count() >= d.battery_slots %}disabled{% endif %}>
{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})
</option>
{% endfor %} {% endfor %}
</select> </select>
<button type="button" class="btn btn-sm btn-primary" <button type="button" class="btn btn-sm btn-primary"
+6
View File
@@ -57,6 +57,12 @@
</td> </td>
<td data-label="Actions" style="white-space:nowrap;"> <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> <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) }}" <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.');"> onsubmit="return confirm('Delete {{ d.name }}? All installed batteries will be unassigned.');">
<button class="btn btn-sm btn-danger" type="submit">Delete</button> <button class="btn btn-sm btn-danger" type="submit">Delete</button>