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:
2026-04-12 12:57:10 -05:00
parent 2e36d5f53c
commit 1f5234a3e9
8 changed files with 403 additions and 131 deletions
+1
View File
@@ -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/
+135 -18
View File
@@ -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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
+1 -1
View File
@@ -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)
+7 -16
View File
@@ -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
View File
@@ -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 %}
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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