Simplify battery management: bulk add, device-level auto-install, mass operations
- Replace single-battery add form with bulk add (brand + count, auto-generated labels) - Add device-level install form: specify brand+qty pairs, system autoselects available batteries - Add bulk actions on dashboard: retire, delete, unassign, change brand (checkbox multi-select) - Keep per-battery assign route for special cases (e.g. known low-capacity battery) - Remove unique constraint on Battery.label (labels are now auto-generated) - Add *.snapshot to .gitignore for DB snapshot files - Rewrite tests: 35 passing (11 new tests for bulk-add, device-install, bulk-actions) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ __pycache__/
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
*.snapshot
|
||||||
instance/
|
instance/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import Flask, render_template, redirect, url_for, request, flash, abort
|
from flask import Flask, render_template, redirect, url_for, request, flash, abort
|
||||||
from sqlalchemy import create_engine
|
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
|
from models import Base, Battery, Device
|
||||||
@@ -37,31 +37,27 @@ def create_app(config_object="config"):
|
|||||||
@app.route("/battery/add", methods=["GET", "POST"])
|
@app.route("/battery/add", methods=["GET", "POST"])
|
||||||
def battery_add():
|
def battery_add():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
label = request.form.get("label", "").strip()
|
|
||||||
brand = request.form.get("brand", "").strip()
|
brand = request.form.get("brand", "").strip()
|
||||||
status = request.form.get("status", "available").strip()
|
|
||||||
notes = request.form.get("notes", "").strip() or None
|
notes = request.form.get("notes", "").strip() or None
|
||||||
|
try:
|
||||||
|
count = max(1, min(50, int(request.form.get("count", 1) or 1)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
count = 1
|
||||||
|
|
||||||
if not label or not brand:
|
if not brand:
|
||||||
flash("Label and brand are required.", "error")
|
flash("Brand is required.", "error")
|
||||||
return render_template("battery_add.html"), 400
|
|
||||||
|
|
||||||
if status not in ("available", "installed", "retired"):
|
|
||||||
status = "available"
|
|
||||||
|
|
||||||
if db.query(Battery).filter_by(label=label).first():
|
|
||||||
flash(f"A battery with label '{label}' already exists.", "error")
|
|
||||||
return render_template("battery_add.html",
|
return render_template("battery_add.html",
|
||||||
form_label=label, form_brand=brand,
|
form_brand="", form_count=1, form_notes=notes or ""), 400
|
||||||
form_status=status, form_notes=notes or ""), 400
|
|
||||||
|
|
||||||
battery = Battery(label=label, brand=brand, status=status, notes=notes)
|
existing = db.query(func.count(Battery.id)).filter_by(brand=brand).scalar()
|
||||||
db.add(battery)
|
for i in range(count):
|
||||||
|
label = f"{brand} {existing + i + 1:03d}"
|
||||||
|
db.add(Battery(label=label, brand=brand, status="available", notes=notes))
|
||||||
db.commit()
|
db.commit()
|
||||||
flash(f"Battery {label} added.", "success")
|
flash(f"Added {count} {brand} batter{'y' if count == 1 else 'ies'}.", "success")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
return render_template("battery_add.html")
|
return render_template("battery_add.html", form_count=1)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — detail
|
# Battery — detail
|
||||||
@@ -190,6 +186,54 @@ def create_app(config_object="config"):
|
|||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
return render_template("battery_delete.html", battery=battery)
|
return render_template("battery_delete.html", battery=battery)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Battery — bulk action
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@app.route("/battery/bulk-action", methods=["POST"])
|
||||||
|
def battery_bulk_action():
|
||||||
|
ids = request.form.getlist("battery_ids", type=int)
|
||||||
|
if not ids:
|
||||||
|
flash("No batteries selected.", "error")
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
|
batteries = db.query(Battery).filter(Battery.id.in_(ids)).all()
|
||||||
|
action = request.form.get("action")
|
||||||
|
n = len(batteries)
|
||||||
|
|
||||||
|
if action == "retire":
|
||||||
|
for b in batteries:
|
||||||
|
b.status = "retired"
|
||||||
|
b.device_id = None
|
||||||
|
db.commit()
|
||||||
|
flash(f"Retired {n} batter{'y' if n == 1 else 'ies'}.", "success")
|
||||||
|
elif action == "delete":
|
||||||
|
for b in batteries:
|
||||||
|
db.delete(b)
|
||||||
|
db.commit()
|
||||||
|
flash(f"Deleted {n} batter{'y' if n == 1 else 'ies'}.", "success")
|
||||||
|
elif action == "unassign":
|
||||||
|
count = sum(1 for b in batteries if b.is_installed())
|
||||||
|
for b in batteries:
|
||||||
|
if b.is_installed():
|
||||||
|
b.status = "available"
|
||||||
|
b.device_id = None
|
||||||
|
db.commit()
|
||||||
|
flash(f"Unassigned {count} batter{'y' if count == 1 else 'ies'}.", "success")
|
||||||
|
elif action == "set_brand":
|
||||||
|
new_brand = request.form.get("new_brand", "").strip()
|
||||||
|
if not new_brand:
|
||||||
|
flash("Brand name is required.", "error")
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
for b in batteries:
|
||||||
|
b.brand = new_brand
|
||||||
|
db.commit()
|
||||||
|
flash(f"Updated brand to '{new_brand}' for {n} batter{'y' if n == 1 else 'ies'}.", "success")
|
||||||
|
else:
|
||||||
|
flash("Unknown action.", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — list
|
# Devices — list
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -248,6 +292,79 @@ def create_app(config_object="config"):
|
|||||||
abort(404)
|
abort(404)
|
||||||
return render_template("device_detail.html", device=device)
|
return render_template("device_detail.html", device=device)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Devices — install batteries
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@app.route("/device/<int:device_id>/install", methods=["POST"])
|
||||||
|
def device_install(device_id):
|
||||||
|
device = db.get(Device, device_id)
|
||||||
|
if device is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
brands = request.form.getlist("brand[]")
|
||||||
|
qtys_raw = request.form.getlist("qty[]")
|
||||||
|
|
||||||
|
pairs = []
|
||||||
|
for brand, qty_raw in zip(brands, qtys_raw):
|
||||||
|
brand = brand.strip()
|
||||||
|
try:
|
||||||
|
qty = int(qty_raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
qty = 0
|
||||||
|
if brand and qty > 0:
|
||||||
|
pairs.append((brand, qty))
|
||||||
|
|
||||||
|
if not pairs:
|
||||||
|
flash("No batteries specified.", "error")
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
|
||||||
|
free_slots = device.battery_slots - device.installed_count()
|
||||||
|
total_requested = sum(qty for _, qty in pairs)
|
||||||
|
if total_requested > free_slots:
|
||||||
|
flash(
|
||||||
|
f"Only {free_slots} slot(s) free, but {total_requested} requested.",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
|
||||||
|
# Validate availability before writing anything
|
||||||
|
for brand, qty in pairs:
|
||||||
|
available_count = (
|
||||||
|
db.query(func.count(Battery.id))
|
||||||
|
.filter_by(brand=brand, status="available")
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
if available_count < qty:
|
||||||
|
flash(
|
||||||
|
f"Need {qty} {brand}, but only {available_count} available.",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
|
||||||
|
# All checks passed — perform installs
|
||||||
|
total_installed = 0
|
||||||
|
for brand, qty in pairs:
|
||||||
|
batch = (
|
||||||
|
db.query(Battery)
|
||||||
|
.filter_by(brand=brand, status="available")
|
||||||
|
.order_by(Battery.id)
|
||||||
|
.limit(qty)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for b in batch:
|
||||||
|
b.status = "installed"
|
||||||
|
b.device_id = device.id
|
||||||
|
total_installed += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
flash(
|
||||||
|
f"Installed {total_installed} batter{'y' if total_installed == 1 else 'ies'}"
|
||||||
|
f" into {device.name}.",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — delete
|
# Devices — delete
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class Battery(Base):
|
|||||||
__tablename__ = "battery"
|
__tablename__ = "battery"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
label = Column(String(50), nullable=False, unique=True)
|
label = Column(String(50), nullable=False)
|
||||||
brand = Column(String(100), nullable=False)
|
brand = Column(String(100), nullable=False)
|
||||||
status = Column(String(20), nullable=False, default="available")
|
status = Column(String(20), nullable=False, default="available")
|
||||||
device_id = Column(Integer, ForeignKey("device.id", ondelete="SET NULL"), nullable=True)
|
device_id = Column(Integer, ForeignKey("device.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Add Battery — Battery Tracker{% endblock %}
|
{% block title %}Add Batteries — Battery Tracker{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Add Battery</h1>
|
<h1>Add Batteries</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form method="post" action="{{ url_for('battery_add') }}">
|
<form method="post" action="{{ url_for('battery_add') }}">
|
||||||
<div class="form-group">
|
|
||||||
<label for="label">Label <span class="text-danger">*</span></label>
|
|
||||||
<input type="text" id="label" name="label" value="{{ form_label|default('') }}"
|
|
||||||
placeholder="e.g. ENL-17" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="brand">Brand <span class="text-danger">*</span></label>
|
<label for="brand">Brand <span class="text-danger">*</span></label>
|
||||||
<input type="text" id="brand" name="brand" value="{{ form_brand|default('') }}"
|
<input type="text" id="brand" name="brand" value="{{ form_brand|default('') }}"
|
||||||
@@ -19,21 +13,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="status">Initial Status</label>
|
<label for="count">Quantity</label>
|
||||||
<select id="status" name="status">
|
<input type="number" id="count" name="count" value="{{ form_count|default(1) }}" min="1" max="50">
|
||||||
<option value="available" {% if form_status|default('available') == 'available' %}selected{% endif %}>Available</option>
|
<small class="text-muted">Labels are auto-generated (e.g. Eneloop 001, Eneloop 002)</small>
|
||||||
<option value="installed" {% if form_status|default('') == 'installed' %}selected{% endif %}>Installed</option>
|
|
||||||
<option value="retired" {% if form_status|default('') == 'retired' %}selected{% endif %}>Retired</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="notes">Notes</label>
|
<label for="notes">Notes</label>
|
||||||
<textarea id="notes" name="notes" placeholder="Optional notes…">{{ form_notes|default('') }}</textarea>
|
<textarea id="notes" name="notes" placeholder="Optional notes applied to all batteries…">{{ form_notes|default('') }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn-primary" type="submit">Add Battery</button>
|
<button class="btn btn-primary" type="submit">Add Batteries</button>
|
||||||
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}">Cancel</a>
|
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+92
-51
@@ -29,60 +29,101 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="table-wrap">
|
<form method="post" action="{{ url_for('battery_bulk_action') }}" id="bulk-form">
|
||||||
<table>
|
|
||||||
<thead>
|
<div id="bulk-toolbar" style="display:none;margin-bottom:0.75rem;padding:0.6rem 0.75rem;background:#f1f5f9;border-radius:6px;display:none;align-items:center;gap:0.5rem;flex-wrap:wrap;">
|
||||||
<tr>
|
<span id="selected-count" style="font-size:0.85rem;color:#64748b;margin-right:0.25rem;"></span>
|
||||||
<th>Label</th>
|
<button class="btn btn-sm btn-warning" name="action" value="unassign" type="submit">Unassign</button>
|
||||||
<th>Brand</th>
|
<button class="btn btn-sm btn-secondary" name="action" value="retire" type="submit">Retire</button>
|
||||||
<th>Status</th>
|
<button class="btn btn-sm btn-danger" name="action" value="delete" type="submit"
|
||||||
<th>Assigned To</th>
|
onclick="return confirm('Permanently delete selected batteries?')">Delete</button>
|
||||||
<th>Actions</th>
|
<span style="display:flex;gap:0.35rem;align-items:center;">
|
||||||
</tr>
|
<input type="text" name="new_brand" id="new-brand-input" placeholder="New brand name"
|
||||||
</thead>
|
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:160px;">
|
||||||
<tbody>
|
<button class="btn btn-sm btn-primary" name="action" value="set_brand" type="submit">Change Brand</button>
|
||||||
{% for b in batteries %}
|
</span>
|
||||||
<tr>
|
</div>
|
||||||
<td><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
|
|
||||||
<td>{{ b.brand }}</td>
|
<div class="table-wrap">
|
||||||
<td>
|
<table>
|
||||||
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
|
<thead>
|
||||||
</td>
|
<tr>
|
||||||
<td>
|
<th style="width:1.5rem;"><input type="checkbox" id="select-all" title="Select all"></th>
|
||||||
{% if b.device %}
|
<th>Label</th>
|
||||||
<a href="{{ url_for('device_detail', device_id=b.device.id) }}">{{ b.device.name }}</a>
|
<th>Brand</th>
|
||||||
{% if b.device.has_mixed_brands() %}
|
<th>Status</th>
|
||||||
<span class="badge badge-warning" title="Mixed brands in this device">⚠ mixed</span>
|
<th>Assigned To</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in batteries %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" name="battery_ids" value="{{ b.id }}" class="row-cb"></td>
|
||||||
|
<td><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
|
||||||
|
<td>{{ b.brand }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if b.device %}
|
||||||
|
<a href="{{ url_for('device_detail', device_id=b.device.id) }}">{{ b.device.name }}</a>
|
||||||
|
{% if b.device.has_mixed_brands() %}
|
||||||
|
<span class="badge badge-warning" title="Mixed brands in this device">⚠ mixed</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
</td>
|
||||||
<span class="text-muted">—</span>
|
<td style="white-space:nowrap;">
|
||||||
{% endif %}
|
<a class="btn btn-sm btn-secondary" href="{{ url_for('battery_detail', battery_id=b.id) }}">View</a>
|
||||||
</td>
|
|
||||||
<td style="white-space:nowrap;">
|
|
||||||
<a class="btn btn-sm btn-secondary" href="{{ url_for('battery_detail', battery_id=b.id) }}">View</a>
|
|
||||||
|
|
||||||
{% if b.is_available() %}
|
{% if b.is_available() %}
|
||||||
<a class="btn btn-sm btn-primary" href="{{ url_for('battery_assign', battery_id=b.id) }}">Assign</a>
|
<a class="btn btn-sm btn-primary" href="{{ url_for('battery_assign', battery_id=b.id) }}">Assign</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if b.is_installed() %}
|
{% if b.is_installed() %}
|
||||||
<form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}">
|
<button class="btn btn-sm btn-warning" type="submit"
|
||||||
<button class="btn btn-sm btn-warning" type="submit">Unassign</button>
|
formaction="{{ url_for('battery_unassign', battery_id=b.id) }}">Unassign</button>
|
||||||
</form>
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if not b.is_retired() %}
|
{% if not b.is_retired() %}
|
||||||
<form class="inline" method="post" action="{{ url_for('battery_retire', battery_id=b.id) }}">
|
<button class="btn btn-sm btn-secondary" type="submit"
|
||||||
<button class="btn btn-sm btn-secondary" type="submit">Retire</button>
|
formaction="{{ url_for('battery_retire', battery_id=b.id) }}">Retire</button>
|
||||||
</form>
|
{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
{% else %}
|
||||||
{% else %}
|
<tr><td colspan="6" class="text-muted" style="text-align:center;padding:1rem;">No batteries found. <a href="{{ url_for('battery_add') }}">Add some.</a></td></tr>
|
||||||
<tr><td colspan="5" class="text-muted" style="text-align:center;padding:1rem;">No batteries found. <a href="{{ url_for('battery_add') }}">Add one.</a></td></tr>
|
{% endfor %}
|
||||||
{% endfor %}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var cbs = document.querySelectorAll('.row-cb');
|
||||||
|
var selectAll = document.getElementById('select-all');
|
||||||
|
var toolbar = document.getElementById('bulk-toolbar');
|
||||||
|
var countEl = document.getElementById('selected-count');
|
||||||
|
|
||||||
|
function updateToolbar() {
|
||||||
|
var checked = document.querySelectorAll('.row-cb:checked');
|
||||||
|
var n = checked.length;
|
||||||
|
toolbar.style.display = n > 0 ? 'flex' : 'none';
|
||||||
|
countEl.textContent = n + ' selected';
|
||||||
|
selectAll.indeterminate = n > 0 && n < cbs.length;
|
||||||
|
selectAll.checked = cbs.length > 0 && n === cbs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
cbs.forEach(function (cb) { cb.addEventListener('change', updateToolbar); });
|
||||||
|
selectAll.addEventListener('change', function () {
|
||||||
|
cbs.forEach(function (cb) { cb.checked = selectAll.checked; });
|
||||||
|
updateToolbar();
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -25,6 +25,24 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Install Batteries</h2>
|
||||||
|
{% set free_slots = device.battery_slots - device.installed_count() %}
|
||||||
|
<p class="text-muted" style="margin-bottom:0.75rem;">{{ free_slots }} slot(s) free</p>
|
||||||
|
<form method="post" action="{{ url_for('device_install', device_id=device.id) }}">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr auto;gap:0.4rem;max-width:360px;align-items:center;margin-bottom:0.75rem;">
|
||||||
|
<span style="font-weight:600;font-size:0.85rem;color:#64748b;">Brand</span>
|
||||||
|
<span style="font-weight:600;font-size:0.85rem;color:#64748b;">Qty</span>
|
||||||
|
{% for _ in range(4) %}
|
||||||
|
<input type="text" name="brand[]" placeholder="e.g. Eneloop" style="padding:0.3rem 0.5rem;">
|
||||||
|
<input type="number" name="qty[]" value="0" min="0"
|
||||||
|
style="padding:0.3rem 0.5rem;width:4rem;text-align:center;">
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Install</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Installed Batteries</h2>
|
<h2>Installed Batteries</h2>
|
||||||
{% set installed = device.batteries | selectattr('status', 'eq', 'installed') | list %}
|
{% set installed = device.batteries | selectattr('status', 'eq', 'installed') | list %}
|
||||||
|
|||||||
+11
-4
@@ -31,11 +31,18 @@ def client(app):
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def seeded_client(app):
|
def seeded_client(app):
|
||||||
"""Test client pre-loaded with 2 devices and 3 batteries (2 available, 1 retired)."""
|
"""Test client pre-loaded with 2 devices and 3 batteries (2 available, 1 retired).
|
||||||
|
|
||||||
|
Batteries:
|
||||||
|
id=1 BrandX 001 (BrandX, available)
|
||||||
|
id=2 BrandY 001 (BrandY, available)
|
||||||
|
id=3 BrandX 002 (BrandX, retired)
|
||||||
|
"""
|
||||||
with app.test_client() as c:
|
with app.test_client() as c:
|
||||||
c.post("/device/add", data={"name": "Device A", "battery_slots": "2"})
|
c.post("/device/add", data={"name": "Device A", "battery_slots": "2"})
|
||||||
c.post("/device/add", data={"name": "Device B", "battery_slots": "1"})
|
c.post("/device/add", data={"name": "Device B", "battery_slots": "1"})
|
||||||
c.post("/battery/add", data={"label": "TST-01", "brand": "BrandX", "status": "available"})
|
c.post("/battery/add", data={"brand": "BrandX", "count": "1"}) # id=1
|
||||||
c.post("/battery/add", data={"label": "TST-02", "brand": "BrandY", "status": "available"})
|
c.post("/battery/add", data={"brand": "BrandY", "count": "1"}) # id=2
|
||||||
c.post("/battery/add", data={"label": "TST-03", "brand": "BrandX", "status": "retired"})
|
c.post("/battery/add", data={"brand": "BrandX", "count": "1"}) # id=3
|
||||||
|
c.post("/battery/3/retire")
|
||||||
yield c
|
yield c
|
||||||
|
|||||||
+138
-41
@@ -4,7 +4,8 @@ Acceptance tests for Battery Tracker.
|
|||||||
Each test gets a fresh in-memory SQLite database via the fixtures in conftest.py.
|
Each test gets a fresh in-memory SQLite database via the fixtures in conftest.py.
|
||||||
The `seeded_client` fixture pre-populates:
|
The `seeded_client` fixture pre-populates:
|
||||||
Devices: Device A (2 slots), Device B (1 slot)
|
Devices: Device A (2 slots), Device B (1 slot)
|
||||||
Batteries: TST-01 (BrandX, available), TST-02 (BrandY, available), TST-03 (BrandX, retired)
|
Batteries: BrandX 001 (id=1, available), BrandY 001 (id=2, available),
|
||||||
|
BrandX 002 (id=3, retired)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -17,7 +18,6 @@ import pytest
|
|||||||
def get_location(resp):
|
def get_location(resp):
|
||||||
"""Return the redirect Location header (without host)."""
|
"""Return the redirect Location header (without host)."""
|
||||||
loc = resp.headers.get("Location", "")
|
loc = resp.headers.get("Location", "")
|
||||||
# Strip scheme+host if present
|
|
||||||
if loc.startswith("http"):
|
if loc.startswith("http"):
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
loc = urlparse(loc).path
|
loc = urlparse(loc).path
|
||||||
@@ -36,34 +36,37 @@ def follow(client, resp):
|
|||||||
def test_dashboard_loads(seeded_client):
|
def test_dashboard_loads(seeded_client):
|
||||||
resp = seeded_client.get("/")
|
resp = seeded_client.get("/")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"TST-01" in resp.data
|
assert b"BrandX 001" in resp.data
|
||||||
assert b"TST-02" in resp.data
|
assert b"BrandY 001" in resp.data
|
||||||
assert b"TST-03" in resp.data
|
assert b"BrandX 002" in resp.data
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — add
|
# Battery — bulk add
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_add_battery(client):
|
def test_bulk_add_batteries(client):
|
||||||
resp = client.post("/battery/add",
|
resp = client.post("/battery/add",
|
||||||
data={"label": "NEW-01", "brand": "TestBrand", "status": "available"},
|
data={"brand": "Eneloop", "count": "3"},
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"NEW-01" in resp.data
|
assert b"Eneloop 001" in resp.data
|
||||||
|
assert b"Eneloop 002" in resp.data
|
||||||
|
assert b"Eneloop 003" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_add_battery_duplicate_label(seeded_client):
|
def test_bulk_add_autogenerates_sequential_labels(client):
|
||||||
resp = seeded_client.post("/battery/add",
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
|
||||||
data={"label": "TST-01", "brand": "OtherBrand", "status": "available"})
|
resp = client.post("/battery/add", data={"brand": "Eneloop", "count": "2"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert b"Eneloop 003" in resp.data
|
||||||
|
assert b"Eneloop 004" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_add_missing_brand(client):
|
||||||
|
resp = client.post("/battery/add", data={"brand": "", "count": "1"})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert b"already exists" in resp.data
|
assert b"required" in resp.data.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_add_battery_missing_fields(client):
|
|
||||||
resp = client.post("/battery/add", data={"label": "", "brand": ""})
|
|
||||||
assert resp.status_code == 400
|
|
||||||
assert b"required" in resp.data
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -73,7 +76,7 @@ def test_add_battery_missing_fields(client):
|
|||||||
def test_battery_detail(seeded_client):
|
def test_battery_detail(seeded_client):
|
||||||
resp = seeded_client.get("/battery/1")
|
resp = seeded_client.get("/battery/1")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"TST-01" in resp.data
|
assert b"BrandX 001" in resp.data
|
||||||
assert b"BrandX" in resp.data
|
assert b"BrandX" in resp.data
|
||||||
|
|
||||||
|
|
||||||
@@ -93,11 +96,10 @@ def test_edit_notes(seeded_client):
|
|||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — assign
|
# Battery — assign (per-battery, kept for special cases)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_assign_battery(seeded_client):
|
def test_assign_battery(seeded_client):
|
||||||
# TST-01 is battery id=1, Device A is device id=1
|
|
||||||
resp = seeded_client.post("/battery/1/assign",
|
resp = seeded_client.post("/battery/1/assign",
|
||||||
data={"device_id": "1"},
|
data={"device_id": "1"},
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
@@ -106,7 +108,7 @@ def test_assign_battery(seeded_client):
|
|||||||
|
|
||||||
|
|
||||||
def test_assign_retired_battery_blocked(seeded_client):
|
def test_assign_retired_battery_blocked(seeded_client):
|
||||||
# TST-03 is retired (id=3)
|
# id=3 is retired
|
||||||
resp = seeded_client.post("/battery/3/assign",
|
resp = seeded_client.post("/battery/3/assign",
|
||||||
data={"device_id": "1"},
|
data={"device_id": "1"},
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
@@ -115,9 +117,9 @@ def test_assign_retired_battery_blocked(seeded_client):
|
|||||||
|
|
||||||
|
|
||||||
def test_assign_over_capacity_blocked(seeded_client):
|
def test_assign_over_capacity_blocked(seeded_client):
|
||||||
# Fill Device B (1 slot) with TST-01
|
# Fill Device B (1 slot) with id=1
|
||||||
seeded_client.post("/battery/1/assign", data={"device_id": "2"})
|
seeded_client.post("/battery/1/assign", data={"device_id": "2"})
|
||||||
# Try to assign TST-02 to the same full device
|
# Try to assign id=2 to the same full device
|
||||||
resp = seeded_client.post("/battery/2/assign",
|
resp = seeded_client.post("/battery/2/assign",
|
||||||
data={"device_id": "2"},
|
data={"device_id": "2"},
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
@@ -126,14 +128,13 @@ def test_assign_over_capacity_blocked(seeded_client):
|
|||||||
|
|
||||||
|
|
||||||
def test_brand_mix_warning(seeded_client):
|
def test_brand_mix_warning(seeded_client):
|
||||||
# Assign TST-01 (BrandX) to Device A
|
# Assign BrandX 001 (id=1) to Device A
|
||||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
# Assign TST-02 (BrandY) to same Device A → brand mix warning, but succeeds
|
# Assign BrandY 001 (id=2) to same Device A → brand mix warning, but succeeds
|
||||||
resp = seeded_client.post("/battery/2/assign",
|
resp = seeded_client.post("/battery/2/assign",
|
||||||
data={"device_id": "1"},
|
data={"device_id": "1"},
|
||||||
follow_redirects=True)
|
follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# Warning flash should mention mixing
|
|
||||||
assert b"mixing" in resp.data.lower() or b"mix" in resp.data.lower() or b"brand" in resp.data.lower()
|
assert b"mixing" in resp.data.lower() or b"mix" in resp.data.lower() or b"brand" in resp.data.lower()
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +146,6 @@ def test_unassign_battery(seeded_client):
|
|||||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
resp = seeded_client.post("/battery/1/unassign", follow_redirects=True)
|
resp = seeded_client.post("/battery/1/unassign", follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# Battery should show as available on dashboard
|
|
||||||
resp2 = seeded_client.get("/battery/1")
|
resp2 = seeded_client.get("/battery/1")
|
||||||
assert b"available" in resp2.data.lower()
|
assert b"available" in resp2.data.lower()
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ def test_retire_clears_device(seeded_client):
|
|||||||
|
|
||||||
|
|
||||||
def test_retire_already_retired(seeded_client):
|
def test_retire_already_retired(seeded_client):
|
||||||
# TST-03 is already retired (id=3)
|
# id=3 is already retired by fixture
|
||||||
resp = seeded_client.post("/battery/3/retire", follow_redirects=True)
|
resp = seeded_client.post("/battery/3/retire", follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"already retired" in resp.data.lower()
|
assert b"already retired" in resp.data.lower()
|
||||||
@@ -183,7 +183,7 @@ def test_delete_battery_confirmation_page(seeded_client):
|
|||||||
resp = seeded_client.get("/battery/1/delete")
|
resp = seeded_client.get("/battery/1/delete")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Confirm Delete" in resp.data
|
assert b"Confirm Delete" in resp.data
|
||||||
assert b"TST-01" in resp.data
|
assert b"BrandX 001" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_delete_battery(seeded_client):
|
def test_delete_battery(seeded_client):
|
||||||
@@ -192,6 +192,108 @@ def test_delete_battery(seeded_client):
|
|||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Battery — bulk actions
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_bulk_retire(client):
|
||||||
|
client.post("/battery/add", data={"brand": "TestBrand", "count": "2"})
|
||||||
|
resp = client.post("/battery/bulk-action",
|
||||||
|
data={"battery_ids": ["1", "2"], "action": "retire"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"retired" in client.get("/battery/1").data.lower()
|
||||||
|
assert b"retired" in client.get("/battery/2").data.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_delete(client):
|
||||||
|
client.post("/battery/add", data={"brand": "TestBrand", "count": "2"})
|
||||||
|
resp = client.post("/battery/bulk-action",
|
||||||
|
data={"battery_ids": ["1", "2"], "action": "delete"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert client.get("/battery/1").status_code == 404
|
||||||
|
assert client.get("/battery/2").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_unassign(client):
|
||||||
|
client.post("/device/add", data={"name": "Gadget", "battery_slots": "2"})
|
||||||
|
client.post("/battery/add", data={"brand": "TestBrand", "count": "2"})
|
||||||
|
client.post("/device/1/install", data={"brand[]": "TestBrand", "qty[]": "2"})
|
||||||
|
resp = client.post("/battery/bulk-action",
|
||||||
|
data={"battery_ids": ["1", "2"], "action": "unassign"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"available" in client.get("/battery/1").data.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_set_brand(client):
|
||||||
|
client.post("/battery/add", data={"brand": "OldBrand", "count": "2"})
|
||||||
|
resp = client.post("/battery/bulk-action",
|
||||||
|
data={"battery_ids": ["1", "2"], "action": "set_brand",
|
||||||
|
"new_brand": "NewBrand"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"NewBrand" in client.get("/battery/1").data
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_action_no_selection(client):
|
||||||
|
client.post("/battery/add", data={"brand": "TestBrand", "count": "1"})
|
||||||
|
resp = client.post("/battery/bulk-action",
|
||||||
|
data={"action": "retire"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"No batteries selected" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Device — install batteries
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_device_install_autoselects(client):
|
||||||
|
client.post("/device/add", data={"name": "Gadget", "battery_slots": "2"})
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "3"})
|
||||||
|
resp = client.post("/device/1/install",
|
||||||
|
data={"brand[]": "Eneloop", "qty[]": "2"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Device detail should show 2 installed batteries
|
||||||
|
assert b"Eneloop" in resp.data
|
||||||
|
assert b"2 / 2" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_install_mixed_brands(client):
|
||||||
|
client.post("/device/add", data={"name": "Remote", "battery_slots": "4"})
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
|
||||||
|
client.post("/battery/add", data={"brand": "Energizer", "count": "2"})
|
||||||
|
resp = client.post("/device/1/install",
|
||||||
|
data={"brand[]": ["Eneloop", "Energizer"], "qty[]": ["2", "2"]},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Eneloop" in resp.data
|
||||||
|
assert b"Energizer" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_install_insufficient_batteries(client):
|
||||||
|
client.post("/device/add", data={"name": "Gadget", "battery_slots": "4"})
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
|
resp = client.post("/device/1/install",
|
||||||
|
data={"brand[]": "Eneloop", "qty[]": "2"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"available" in resp.data.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_install_over_capacity(client):
|
||||||
|
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "3"})
|
||||||
|
resp = client.post("/device/1/install",
|
||||||
|
data={"brand[]": "Eneloop", "qty[]": "3"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"slot" in resp.data.lower()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Device — list
|
# Device — list
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -248,12 +350,9 @@ def test_device_detail_not_found(client):
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_delete_device_frees_batteries(seeded_client):
|
def test_delete_device_frees_batteries(seeded_client):
|
||||||
# Assign TST-01 to Device A
|
|
||||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
# Delete Device A
|
|
||||||
resp = seeded_client.post("/device/1/delete", follow_redirects=True)
|
resp = seeded_client.post("/device/1/delete", follow_redirects=True)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# TST-01 should now be available
|
|
||||||
resp2 = seeded_client.get("/battery/1")
|
resp2 = seeded_client.get("/battery/1")
|
||||||
assert b"available" in resp2.data.lower()
|
assert b"available" in resp2.data.lower()
|
||||||
|
|
||||||
@@ -268,14 +367,12 @@ def test_delete_device_removed(seeded_client):
|
|||||||
# Full round-trip
|
# Full round-trip
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_add_assign_delete_battery(client):
|
def test_add_install_delete_battery(client):
|
||||||
# Add a device
|
|
||||||
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
|
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
|
||||||
# Add a battery
|
client.post("/battery/add", data={"brand": "AcmeBrand", "count": "1"})
|
||||||
client.post("/battery/add", data={"label": "RT-01", "brand": "AcmeBrand", "status": "available"})
|
resp = client.post("/device/1/install",
|
||||||
# Assign it
|
data={"brand[]": "AcmeBrand", "qty[]": "1"},
|
||||||
resp = client.post("/battery/1/assign", data={"device_id": "1"}, follow_redirects=True)
|
follow_redirects=True)
|
||||||
assert b"Gadget" in resp.data
|
assert b"Gadget" in resp.data
|
||||||
# Delete it
|
|
||||||
client.post("/battery/1/delete")
|
client.post("/battery/1/delete")
|
||||||
assert client.get("/battery/1").status_code == 404
|
assert client.get("/battery/1").status_code == 404
|
||||||
|
|||||||
Reference in New Issue
Block a user