Initial commit: Flask battery tracker app
- Flask + SQLAlchemy (MariaDB-compatible schema) battery tracking web app - 40 pre-seeded batteries (Eneloop, BONAI, Energizer NiMH) across 5 devices - Business rules: block retired assignment, brand-mix warnings, capacity checks - Mobile-friendly Jinja2 templates with inline CSS - waitress WSGI server via systemd user service (sbin/install-service.sh) - SQLite → MariaDB migration script (migrate_to_mariadb.py) - 26 passing acceptance tests (pytest + Flask test client) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
from flask import Flask, render_template, redirect, url_for, request, flash, abort
|
||||
from sqlalchemy import create_engine
|
||||
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":
|
||||
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
|
||||
|
||||
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")
|
||||
return render_template("battery_add.html",
|
||||
form_label=label, form_brand=brand,
|
||||
form_status=status, form_notes=notes or ""), 400
|
||||
|
||||
battery = Battery(label=label, brand=brand, status=status, notes=notes)
|
||||
db.add(battery)
|
||||
db.commit()
|
||||
flash(f"Battery {label} added.", "success")
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
return render_template("battery_add.html")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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 — 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)
|
||||
Reference in New Issue
Block a user