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-shm
*.db-wal
*.snapshot
instance/
.pytest_cache/
*.egg-info/
+135 -18
View File
@@ -1,5 +1,5 @@
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 models import Base, Battery, Device
@@ -37,31 +37,27 @@ def create_app(config_object="config"):
@app.route("/battery/add", methods=["GET", "POST"])
def battery_add():
if request.method == "POST":
label = request.form.get("label", "").strip()
brand = request.form.get("brand", "").strip()
status = request.form.get("status", "available").strip()
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:
flash("Label and brand are 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")
if not brand:
flash("Brand is required.", "error")
return render_template("battery_add.html",
form_label=label, form_brand=brand,
form_status=status, form_notes=notes or ""), 400
form_brand="", form_count=1, form_notes=notes or ""), 400
battery = Battery(label=label, brand=brand, status=status, notes=notes)
db.add(battery)
existing = db.query(func.count(Battery.id)).filter_by(brand=brand).scalar()
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()
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 render_template("battery_add.html")
return render_template("battery_add.html", form_count=1)
# ------------------------------------------------------------------ #
# Battery — detail
@@ -190,6 +186,54 @@ def create_app(config_object="config"):
return redirect(url_for("dashboard"))
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
# ------------------------------------------------------------------ #
@@ -248,6 +292,79 @@ def create_app(config_object="config"):
abort(404)
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
# ------------------------------------------------------------------ #
+1 -1
View File
@@ -31,7 +31,7 @@ class Battery(Base):
__tablename__ = "battery"
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)
status = Column(String(20), nullable=False, default="available")
device_id = Column(Integer, ForeignKey("device.id", ondelete="SET NULL"), nullable=True)
+7 -16
View File
@@ -1,17 +1,11 @@
{% extends "base.html" %}
{% block title %}Add Battery — Battery Tracker{% endblock %}
{% block title %}Add Batteries — Battery Tracker{% endblock %}
{% block content %}
<h1>Add Battery</h1>
<h1>Add Batteries</h1>
<div class="card">
<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">
<label for="brand">Brand <span class="text-danger">*</span></label>
<input type="text" id="brand" name="brand" value="{{ form_brand|default('') }}"
@@ -19,21 +13,18 @@
</div>
<div class="form-group">
<label for="status">Initial Status</label>
<select id="status" name="status">
<option value="available" {% if form_status|default('available') == 'available' %}selected{% endif %}>Available</option>
<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>
<label for="count">Quantity</label>
<input type="number" id="count" name="count" value="{{ form_count|default(1) }}" min="1" max="50">
<small class="text-muted">Labels are auto-generated (e.g. Eneloop 001, Eneloop 002)</small>
</div>
<div class="form-group">
<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 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>
</div>
</form>
+92 -51
View File
@@ -29,60 +29,101 @@
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Label</th>
<th>Brand</th>
<th>Status</th>
<th>Assigned To</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for b in batteries %}
<tr>
<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>
<form method="post" action="{{ url_for('battery_bulk_action') }}" id="bulk-form">
<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;">
<span id="selected-count" style="font-size:0.85rem;color:#64748b;margin-right:0.25rem;"></span>
<button class="btn btn-sm btn-warning" name="action" value="unassign" type="submit">Unassign</button>
<button class="btn btn-sm btn-secondary" name="action" value="retire" type="submit">Retire</button>
<button class="btn btn-sm btn-danger" name="action" value="delete" type="submit"
onclick="return confirm('Permanently delete selected batteries?')">Delete</button>
<span style="display:flex;gap:0.35rem;align-items:center;">
<input type="text" name="new_brand" id="new-brand-input" placeholder="New brand name"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:160px;">
<button class="btn btn-sm btn-primary" name="action" value="set_brand" type="submit">Change Brand</button>
</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:1.5rem;"><input type="checkbox" id="select-all" title="Select all"></th>
<th>Label</th>
<th>Brand</th>
<th>Status</th>
<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 %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td style="white-space:nowrap;">
<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() %}
<a class="btn btn-sm btn-primary" href="{{ url_for('battery_assign', battery_id=b.id) }}">Assign</a>
{% endif %}
{% if b.is_available() %}
<a class="btn btn-sm btn-primary" href="{{ url_for('battery_assign', battery_id=b.id) }}">Assign</a>
{% endif %}
{% 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">Unassign</button>
</form>
{% endif %}
{% if b.is_installed() %}
<button class="btn btn-sm btn-warning" type="submit"
formaction="{{ url_for('battery_unassign', battery_id=b.id) }}">Unassign</button>
{% endif %}
{% 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">Retire</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<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 %}
</tbody>
</table>
</div>
{% if not b.is_retired() %}
<button class="btn btn-sm btn-secondary" type="submit"
formaction="{{ url_for('battery_retire', battery_id=b.id) }}">Retire</button>
{% endif %}
</td>
</tr>
{% 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>
{% endfor %}
</tbody>
</table>
</div>
</form>
</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 %}
+18
View File
@@ -25,6 +25,24 @@
</table>
</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">
<h2>Installed Batteries</h2>
{% set installed = device.batteries | selectattr('status', 'eq', 'installed') | list %}
+11 -4
View File
@@ -31,11 +31,18 @@ def client(app):
@pytest.fixture()
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:
c.post("/device/add", data={"name": "Device A", "battery_slots": "2"})
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={"label": "TST-02", "brand": "BrandY", "status": "available"})
c.post("/battery/add", data={"label": "TST-03", "brand": "BrandX", "status": "retired"})
c.post("/battery/add", data={"brand": "BrandX", "count": "1"}) # id=1
c.post("/battery/add", data={"brand": "BrandY", "count": "1"}) # id=2
c.post("/battery/add", data={"brand": "BrandX", "count": "1"}) # id=3
c.post("/battery/3/retire")
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.
The `seeded_client` fixture pre-populates:
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
@@ -17,7 +18,6 @@ import pytest
def get_location(resp):
"""Return the redirect Location header (without host)."""
loc = resp.headers.get("Location", "")
# Strip scheme+host if present
if loc.startswith("http"):
from urllib.parse import urlparse
loc = urlparse(loc).path
@@ -36,34 +36,37 @@ def follow(client, resp):
def test_dashboard_loads(seeded_client):
resp = seeded_client.get("/")
assert resp.status_code == 200
assert b"TST-01" in resp.data
assert b"TST-02" in resp.data
assert b"TST-03" in resp.data
assert b"BrandX 001" in resp.data
assert b"BrandY 001" 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",
data={"label": "NEW-01", "brand": "TestBrand", "status": "available"},
data={"brand": "Eneloop", "count": "3"},
follow_redirects=True)
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):
resp = seeded_client.post("/battery/add",
data={"label": "TST-01", "brand": "OtherBrand", "status": "available"})
def test_bulk_add_autogenerates_sequential_labels(client):
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
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 b"already exists" in resp.data
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
assert b"required" in resp.data.lower()
# ------------------------------------------------------------------ #
@@ -73,7 +76,7 @@ def test_add_battery_missing_fields(client):
def test_battery_detail(seeded_client):
resp = seeded_client.get("/battery/1")
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
@@ -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):
# TST-01 is battery id=1, Device A is device id=1
resp = seeded_client.post("/battery/1/assign",
data={"device_id": "1"},
follow_redirects=True)
@@ -106,7 +108,7 @@ def test_assign_battery(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",
data={"device_id": "1"},
follow_redirects=True)
@@ -115,9 +117,9 @@ def test_assign_retired_battery_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"})
# 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",
data={"device_id": "2"},
follow_redirects=True)
@@ -126,14 +128,13 @@ def test_assign_over_capacity_blocked(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"})
# 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",
data={"device_id": "1"},
follow_redirects=True)
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()
@@ -145,7 +146,6 @@ def test_unassign_battery(seeded_client):
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
resp = seeded_client.post("/battery/1/unassign", follow_redirects=True)
assert resp.status_code == 200
# Battery should show as available on dashboard
resp2 = seeded_client.get("/battery/1")
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):
# TST-03 is already retired (id=3)
# id=3 is already retired by fixture
resp = seeded_client.post("/battery/3/retire", follow_redirects=True)
assert resp.status_code == 200
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")
assert resp.status_code == 200
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):
@@ -192,6 +192,108 @@ def test_delete_battery(seeded_client):
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
# ------------------------------------------------------------------ #
@@ -248,12 +350,9 @@ def test_device_detail_not_found(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"})
# Delete Device A
resp = seeded_client.post("/device/1/delete", follow_redirects=True)
assert resp.status_code == 200
# TST-01 should now be available
resp2 = seeded_client.get("/battery/1")
assert b"available" in resp2.data.lower()
@@ -268,14 +367,12 @@ def test_delete_device_removed(seeded_client):
# Full round-trip
# ------------------------------------------------------------------ #
def test_add_assign_delete_battery(client):
# Add a device
def test_add_install_delete_battery(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
# Add a battery
client.post("/battery/add", data={"label": "RT-01", "brand": "AcmeBrand", "status": "available"})
# Assign it
resp = client.post("/battery/1/assign", data={"device_id": "1"}, follow_redirects=True)
client.post("/battery/add", data={"brand": "AcmeBrand", "count": "1"})
resp = client.post("/device/1/install",
data={"brand[]": "AcmeBrand", "qty[]": "1"},
follow_redirects=True)
assert b"Gadget" in resp.data
# Delete it
client.post("/battery/1/delete")
assert client.get("/battery/1").status_code == 404