Add inline assign from dashboard, specific battery picker on device, dynamic install rows
- Dashboard: replace Assign link with device dropdown + arrow button for quick inline assignment without leaving the page - Device detail: replace hardcoded 4-row install form with 1 row + '+ Add brand' button that clones rows dynamically - Device detail: add 'Install Specific Battery' card with dropdown of all available batteries (label, brand, size, notes) via new /device/<id>/install-one route - Tests: 4 new acceptance tests covering dashboard quick-assign and install-one, including capacity enforcement on both paths (39 total)
This commit is contained in:
@@ -33,8 +33,9 @@ def create_app(config_object="config"):
|
|||||||
.filter(Battery.storage_location.isnot(None))
|
.filter(Battery.storage_location.isnot(None))
|
||||||
.distinct().order_by(Battery.storage_location).all()
|
.distinct().order_by(Battery.storage_location).all()
|
||||||
]
|
]
|
||||||
|
devices = db.query(Device).order_by(Device.name).all()
|
||||||
return render_template("dashboard.html", batteries=batteries,
|
return render_template("dashboard.html", batteries=batteries,
|
||||||
storage_locations=storage_locations)
|
storage_locations=storage_locations, devices=devices)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — add
|
# Battery — add
|
||||||
@@ -361,7 +362,11 @@ def create_app(config_object="config"):
|
|||||||
if device is None:
|
if device is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()]
|
brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()]
|
||||||
return render_template("device_detail.html", device=device, brands=brands)
|
available_batteries = (db.query(Battery)
|
||||||
|
.filter_by(status="available")
|
||||||
|
.order_by(Battery.label).all())
|
||||||
|
return render_template("device_detail.html", device=device, brands=brands,
|
||||||
|
available_batteries=available_batteries)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — install batteries
|
# Devices — install batteries
|
||||||
@@ -436,6 +441,40 @@ def create_app(config_object="config"):
|
|||||||
)
|
)
|
||||||
return redirect(url_for("device_detail", device_id=device_id))
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Devices — install one specific battery
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@app.route("/device/<int:device_id>/install-one", methods=["POST"])
|
||||||
|
def device_install_one(device_id):
|
||||||
|
device = db.get(Device, device_id)
|
||||||
|
if device is None:
|
||||||
|
abort(404)
|
||||||
|
battery_id = request.form.get("battery_id", type=int)
|
||||||
|
battery = db.get(Battery, battery_id)
|
||||||
|
if battery is None or not battery.is_available():
|
||||||
|
flash("Battery not found or not available.", "error")
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
if device.installed_count() >= device.battery_slots:
|
||||||
|
flash(
|
||||||
|
f"{device.name} is full "
|
||||||
|
f"({device.battery_slots}/{device.battery_slots} slots used).",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
existing_brands = device.installed_brands()
|
||||||
|
if existing_brands and battery.brand not in existing_brands:
|
||||||
|
flash(
|
||||||
|
f"Warning: {device.name} already has batteries from "
|
||||||
|
f"{', '.join(existing_brands)}. Mixing brands is not recommended.",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
battery.status = "installed"
|
||||||
|
battery.device_id = device.id
|
||||||
|
db.commit()
|
||||||
|
flash(f"{battery.label} installed in {device.name}.", "success")
|
||||||
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — delete
|
# Devices — delete
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -167,7 +167,18 @@
|
|||||||
<a class="btn btn-sm btn-secondary" href="{{ url_for('battery_detail', battery_id=b.id) }}">View</a>
|
<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>
|
<select id="qas-{{ b.id }}"
|
||||||
|
style="padding:0.2rem 0.3rem;font-size:0.8rem;border:1px solid #cbd5e1;border-radius:4px;max-width:110px;vertical-align:middle;">
|
||||||
|
<option value="">— assign —</option>
|
||||||
|
{% for d in devices %}
|
||||||
|
<option value="{{ d.id }}"
|
||||||
|
{% if d.installed_count() >= d.battery_slots %}disabled{% endif %}>
|
||||||
|
{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary"
|
||||||
|
onclick="quickAssign('{{ url_for('battery_assign', battery_id=b.id) }}', {{ b.id }})">→</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if b.is_installed() %}
|
{% if b.is_installed() %}
|
||||||
@@ -249,6 +260,18 @@ function applyFilters() {
|
|||||||
updateToolbar();
|
updateToolbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quickAssign(action, batteryId) {
|
||||||
|
var sel = document.getElementById('qas-' + batteryId);
|
||||||
|
if (!sel.value) { sel.focus(); return; }
|
||||||
|
var f = document.createElement('form');
|
||||||
|
f.method = 'post'; f.action = action;
|
||||||
|
var inp = document.createElement('input');
|
||||||
|
inp.type = 'hidden'; inp.name = 'device_id'; inp.value = sel.value;
|
||||||
|
f.appendChild(inp);
|
||||||
|
document.body.appendChild(f);
|
||||||
|
f.submit();
|
||||||
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
['filter-status','filter-brand','filter-size','filter-storage'].forEach(function(id) {
|
['filter-status','filter-brand','filter-size','filter-storage'].forEach(function(id) {
|
||||||
document.getElementById(id).value = '';
|
document.getElementById(id).value = '';
|
||||||
|
|||||||
@@ -30,41 +30,51 @@
|
|||||||
{% set free_slots = device.battery_slots - device.installed_count() %}
|
{% set free_slots = device.battery_slots - device.installed_count() %}
|
||||||
<p class="text-muted" style="margin-bottom:0.75rem;">{{ free_slots }} slot(s) free</p>
|
<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) }}">
|
<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:400px;align-items:start;margin-bottom:0.75rem;">
|
<div id="install-grid" style="display:grid;grid-template-columns:1fr auto;gap:0.4rem;max-width:400px;align-items:start;margin-bottom:0.5rem;">
|
||||||
<span style="font-weight:600;font-size:0.85rem;color:#64748b;">Brand</span>
|
<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>
|
<span style="font-weight:600;font-size:0.85rem;color:#64748b;">Qty</span>
|
||||||
{% for i in range(1, 5) %}
|
<div class="install-row-pair" style="display:contents;">
|
||||||
<div>
|
<div>
|
||||||
<select onchange="brandSelectChanged(this, 'brand-{{ i }}')">
|
<select onchange="brandSelectChanged(this)">
|
||||||
<option value="">— select —</option>
|
<option value="">— select —</option>
|
||||||
{% for b in brands|default([]) %}
|
{% for b in brands|default([]) %}
|
||||||
<option value="{{ b }}">{{ b }}</option>
|
<option value="{{ b }}">{{ b }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<option value="__new__">➕ New brand…</option>
|
<option value="__new__">➕ New brand…</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" id="brand-{{ i }}" name="brand[]" value=""
|
<input type="text" name="brand[]" value=""
|
||||||
placeholder="Type brand name"
|
placeholder="Type brand name"
|
||||||
style="display:none;margin-top:0.3rem;padding:0.3rem 0.5rem;">
|
style="display:none;margin-top:0.3rem;padding:0.3rem 0.5rem;">
|
||||||
</div>
|
</div>
|
||||||
<input type="number" name="qty[]" value="0" min="0"
|
<input type="number" name="qty[]" value="0" min="0"
|
||||||
style="padding:0.3rem 0.5rem;width:4rem;text-align:center;">
|
style="padding:0.3rem 0.5rem;width:4rem;text-align:center;">
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="addInstallRow()" style="margin-bottom:0.75rem;">+ Add brand</button>
|
||||||
|
<br>
|
||||||
<button class="btn btn-primary" type="submit">Install</button>
|
<button class="btn btn-primary" type="submit">Install</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function brandSelectChanged(sel, inputId) {
|
function brandSelectChanged(sel) {
|
||||||
var input = document.getElementById(inputId);
|
var input = sel.nextElementSibling;
|
||||||
if (sel.value === '__new__' || sel.value === '') {
|
if (sel.value === '__new__') {
|
||||||
input.style.display = sel.value === '__new__' ? '' : 'none';
|
input.style.display = ''; input.value = ''; input.focus();
|
||||||
input.value = '';
|
|
||||||
if (sel.value === '__new__') input.focus();
|
|
||||||
} else {
|
} else {
|
||||||
input.style.display = 'none';
|
input.style.display = 'none';
|
||||||
input.value = sel.value;
|
input.value = (sel.value === '') ? '' : sel.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function addInstallRow() {
|
||||||
|
var grid = document.getElementById('install-grid');
|
||||||
|
var tmpl = grid.querySelector('.install-row-pair');
|
||||||
|
var clone = tmpl.cloneNode(true);
|
||||||
|
clone.querySelector('select').value = '';
|
||||||
|
clone.querySelector('input[type=text]').style.display = 'none';
|
||||||
|
clone.querySelector('input[type=text]').value = '';
|
||||||
|
clone.querySelector('input[type=number]').value = '0';
|
||||||
|
grid.appendChild(clone);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,6 +108,28 @@ function brandSelectChanged(sel, inputId) {
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Install Specific Battery</h2>
|
||||||
|
{% if available_batteries %}
|
||||||
|
<form method="post" action="{{ url_for('device_install_one', device_id=device.id) }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="battery_id">Battery</label>
|
||||||
|
<select name="battery_id" id="battery_id">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{% for b in available_batteries %}
|
||||||
|
<option value="{{ b.id }}">{{ b.label }} — {{ b.brand }}
|
||||||
|
{%- if b.size %} {{ b.size }}{% endif %}
|
||||||
|
{%- if b.notes %} ({{ b.notes }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Install</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No available batteries.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Delete Device</h2>
|
<h2>Delete Device</h2>
|
||||||
<p style="margin-bottom:1rem;" class="text-muted">
|
<p style="margin-bottom:1rem;" class="text-muted">
|
||||||
|
|||||||
@@ -294,6 +294,56 @@ def test_device_install_over_capacity(client):
|
|||||||
assert b"slot" in resp.data.lower()
|
assert b"slot" in resp.data.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Dashboard — quick-assign
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_dashboard_quick_assign(client):
|
||||||
|
"""Quick-assign from dashboard (battery_assign POST) succeeds."""
|
||||||
|
client.post("/device/add", data={"name": "Box", "battery_slots": "2"})
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
|
resp = client.post("/battery/1/assign", data={"device_id": "1"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Box" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_quick_assign_full_device_blocked(client):
|
||||||
|
"""Quick-assign from dashboard to a full device is blocked."""
|
||||||
|
client.post("/device/add", data={"name": "Box", "battery_slots": "1"})
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
|
||||||
|
client.post("/battery/1/assign", data={"device_id": "1"}) # fills the slot
|
||||||
|
resp = client.post("/battery/2/assign", data={"device_id": "1"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"full" in resp.data.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Device — install-one (specific battery)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_install_one_specific_battery(client):
|
||||||
|
"""device_install_one installs a chosen battery."""
|
||||||
|
client.post("/device/add", data={"name": "Box", "battery_slots": "2"})
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
|
resp = client.post("/device/1/install-one", data={"battery_id": "1"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"Eneloop 001" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_one_over_capacity_blocked(client):
|
||||||
|
"""device_install_one to a full device is blocked."""
|
||||||
|
client.post("/device/add", data={"name": "Box", "battery_slots": "1"})
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
|
||||||
|
client.post("/device/1/install-one", data={"battery_id": "1"}) # fills slot
|
||||||
|
resp = client.post("/device/1/install-one", data={"battery_id": "2"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"full" in resp.data.lower()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Device — list
|
# Device — list
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
Reference in New Issue
Block a user