1f5234a3e9
- 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>
393 lines
15 KiB
Python
393 lines
15 KiB
Python
from flask import Flask, render_template, redirect, url_for, request, flash, abort
|
|
from sqlalchemy import create_engine, func
|
|
from sqlalchemy.orm import scoped_session, sessionmaker
|
|
|
|
from models import Base, Battery, Device
|
|
|
|
|
|
def create_app(config_object="config"):
|
|
app = Flask(__name__)
|
|
app.config.from_object(config_object)
|
|
|
|
engine = create_engine(
|
|
app.config["SQLALCHEMY_DATABASE_URI"],
|
|
pool_pre_ping=True,
|
|
)
|
|
Base.metadata.create_all(engine)
|
|
|
|
db = scoped_session(sessionmaker(bind=engine))
|
|
|
|
@app.teardown_appcontext
|
|
def remove_session(exc=None):
|
|
db.remove()
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Dashboard
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/")
|
|
def dashboard():
|
|
batteries = db.query(Battery).order_by(Battery.label).all()
|
|
return render_template("dashboard.html", batteries=batteries)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Battery — add
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/battery/add", methods=["GET", "POST"])
|
|
def battery_add():
|
|
if request.method == "POST":
|
|
brand = request.form.get("brand", "").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 brand:
|
|
flash("Brand is required.", "error")
|
|
return render_template("battery_add.html",
|
|
form_brand="", form_count=1, form_notes=notes or ""), 400
|
|
|
|
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"Added {count} {brand} batter{'y' if count == 1 else 'ies'}.", "success")
|
|
return redirect(url_for("dashboard"))
|
|
|
|
return render_template("battery_add.html", form_count=1)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Battery — detail
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/battery/<int:battery_id>")
|
|
def battery_detail(battery_id):
|
|
battery = db.get(Battery, battery_id)
|
|
if battery is None:
|
|
abort(404)
|
|
return render_template("battery_detail.html", battery=battery)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Battery — edit notes
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/battery/<int:battery_id>/edit-notes", methods=["POST"])
|
|
def battery_edit_notes(battery_id):
|
|
battery = db.get(Battery, battery_id)
|
|
if battery is None:
|
|
abort(404)
|
|
battery.notes = request.form.get("notes", "").strip() or None
|
|
db.commit()
|
|
flash("Notes updated.", "success")
|
|
return redirect(url_for("battery_detail", battery_id=battery_id))
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Battery — assign
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/battery/<int:battery_id>/assign", methods=["GET", "POST"])
|
|
def battery_assign(battery_id):
|
|
battery = db.get(Battery, battery_id)
|
|
if battery is None:
|
|
abort(404)
|
|
|
|
if battery.is_retired():
|
|
flash("Cannot assign a retired battery.", "error")
|
|
return redirect(url_for("battery_detail", battery_id=battery_id))
|
|
|
|
devices = db.query(Device).order_by(Device.name).all()
|
|
|
|
if request.method == "POST":
|
|
device_id = request.form.get("device_id", type=int)
|
|
device = db.get(Device, device_id)
|
|
if device is None:
|
|
flash("Device not found.", "error")
|
|
return render_template("assign.html", battery=battery, devices=devices)
|
|
|
|
if device.installed_count() >= device.battery_slots:
|
|
flash(
|
|
f"{device.name} is already full "
|
|
f"({device.battery_slots}/{device.battery_slots} slots used).",
|
|
"error",
|
|
)
|
|
return render_template("assign.html", battery=battery, devices=devices)
|
|
|
|
# Warn on brand mix (non-blocking)
|
|
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",
|
|
)
|
|
|
|
# Unassign from previous device if needed
|
|
if battery.device_id is not None:
|
|
battery.device_id = None
|
|
|
|
battery.status = "installed"
|
|
battery.device_id = device.id
|
|
db.commit()
|
|
flash(f"{battery.label} assigned to {device.name}.", "success")
|
|
return redirect(url_for("dashboard"))
|
|
|
|
return render_template("assign.html", battery=battery, devices=devices)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Battery — unassign
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/battery/<int:battery_id>/unassign", methods=["POST"])
|
|
def battery_unassign(battery_id):
|
|
battery = db.get(Battery, battery_id)
|
|
if battery is None:
|
|
abort(404)
|
|
battery.status = "available"
|
|
battery.device_id = None
|
|
db.commit()
|
|
flash(f"{battery.label} unassigned and marked available.", "success")
|
|
return redirect(url_for("dashboard"))
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Battery — retire
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/battery/<int:battery_id>/retire", methods=["POST"])
|
|
def battery_retire(battery_id):
|
|
battery = db.get(Battery, battery_id)
|
|
if battery is None:
|
|
abort(404)
|
|
if battery.is_retired():
|
|
flash(f"{battery.label} is already retired.", "error")
|
|
return redirect(url_for("battery_detail", battery_id=battery_id))
|
|
battery.status = "retired"
|
|
battery.device_id = None
|
|
db.commit()
|
|
flash(f"{battery.label} has been retired.", "success")
|
|
return redirect(url_for("dashboard"))
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Battery — delete
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/battery/<int:battery_id>/delete", methods=["GET", "POST"])
|
|
def battery_delete(battery_id):
|
|
battery = db.get(Battery, battery_id)
|
|
if battery is None:
|
|
abort(404)
|
|
if request.method == "POST":
|
|
label = battery.label
|
|
db.delete(battery)
|
|
db.commit()
|
|
flash(f"Battery {label} permanently deleted.", "success")
|
|
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
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/device/")
|
|
def device_list():
|
|
devices = db.query(Device).order_by(Device.name).all()
|
|
return render_template("device_list.html", devices=devices)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Devices — add
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/device/add", methods=["GET", "POST"])
|
|
def device_add():
|
|
if request.method == "POST":
|
|
name = request.form.get("name", "").strip()
|
|
slots_raw = request.form.get("battery_slots", "1").strip()
|
|
notes = request.form.get("notes", "").strip() or None
|
|
|
|
if not name:
|
|
flash("Device name is required.", "error")
|
|
return render_template("device_add.html"), 400
|
|
|
|
try:
|
|
slots = int(slots_raw)
|
|
if slots < 1:
|
|
raise ValueError
|
|
except ValueError:
|
|
flash("Battery slots must be a positive integer.", "error")
|
|
return render_template("device_add.html",
|
|
form_name=name, form_notes=notes or ""), 400
|
|
|
|
if db.query(Device).filter_by(name=name).first():
|
|
flash(f"A device named '{name}' already exists.", "error")
|
|
return render_template("device_add.html",
|
|
form_name=name, form_slots=slots,
|
|
form_notes=notes or ""), 400
|
|
|
|
device = Device(name=name, battery_slots=slots, notes=notes)
|
|
db.add(device)
|
|
db.commit()
|
|
flash(f"Device '{name}' added.", "success")
|
|
return redirect(url_for("device_list"))
|
|
|
|
return render_template("device_add.html")
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Devices — detail
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/device/<int:device_id>")
|
|
def device_detail(device_id):
|
|
device = db.get(Device, device_id)
|
|
if device is None:
|
|
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
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@app.route("/device/<int:device_id>/delete", methods=["POST"])
|
|
def device_delete(device_id):
|
|
device = db.get(Device, device_id)
|
|
if device is None:
|
|
abort(404)
|
|
for battery in device.batteries:
|
|
battery.status = "available"
|
|
battery.device_id = None
|
|
name = device.name
|
|
db.delete(device)
|
|
db.commit()
|
|
flash(f"Device '{name}' deleted. All batteries marked available.", "success")
|
|
return redirect(url_for("device_list"))
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|
|
|
|
if __name__ == "__main__":
|
|
app.run(debug=True)
|