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:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}>"
|
||||||
|
|||||||
+6
-13
@@ -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;background:#fff;">
|
||||||
<div class="device-option" style="margin-bottom:0.75rem;padding:0.75rem;border:1px solid #e2e8f0;border-radius:4px;
|
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;min-height:44px;cursor:pointer;">
|
||||||
{% if full %}opacity:0.5;{% endif %}background:#fff;">
|
<input type="radio" name="device_id" value="{{ device.id }}" style="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 }}"
|
|
||||||
{% 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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user