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:
2026-04-11 22:38:16 -05:00
commit 6ea3eae981
22 changed files with 1689 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.pyc
*.db
*.db-shm
*.db-wal
instance/
.pytest_cache/
*.egg-info/
dist/
+164
View File
@@ -0,0 +1,164 @@
# Migrating from SQLite to MariaDB
This guide covers moving the Battery Tracker database from SQLite to MariaDB/MySQL
without changing any application code — only the connection string changes.
---
## 1. Install PyMySQL
```bash
source .venv/bin/activate
pip install pymysql
pip freeze > requirements.txt
```
---
## 2. Create the MariaDB database and user
```sql
CREATE DATABASE batteries CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'battuser'@'localhost' IDENTIFIED BY 'strongpassword';
GRANT ALL PRIVILEGES ON batteries.* TO 'battuser'@'localhost';
FLUSH PRIVILEGES;
```
---
## 3. Update the connection string
Set the `DATABASE_URL` environment variable before starting the app:
```bash
export DATABASE_URL='mysql+pymysql://battuser:strongpassword@localhost/batteries?charset=utf8mb4'
```
Or add it to a `.env` file and load it with your process manager / systemd service:
```ini
# In ~/.config/systemd/user/battery-tracker.service, add under [Service]:
Environment=DATABASE_URL=mysql+pymysql://battuser:strongpassword@localhost/batteries?charset=utf8mb4
```
---
## 4. (Optional) Use Flask-Migrate for schema management
Flask-Migrate lets you apply schema changes incrementally via `flask db upgrade`
rather than dropping and recreating tables.
```bash
pip install Flask-Migrate
```
In `app.py`, after the `db` setup, add:
```python
from flask_migrate import Migrate
migrate = Migrate(app, db) # pass your SQLAlchemy db object
```
Then:
```bash
flask db init # first time only — creates migrations/ directory
flask db migrate -m "initial schema"
flask db upgrade
```
> **Note:** The app uses raw SQLAlchemy (not Flask-SQLAlchemy), so you will need
> to adapt the `Migrate(app, db)` call to wrap your engine/session. Alternatively,
> run `Base.metadata.create_all(engine)` directly for the initial schema — the app
> already does this on startup.
---
## 5. Run the migration script
The `migrate_to_mariadb.py` script reads every record from SQLite and inserts
it into MariaDB using the SQLAlchemy ORM — no raw SQL, no CSV exports.
```bash
MARIADB_URL='mysql+pymysql://battuser:strongpassword@localhost/batteries?charset=utf8mb4' \
python migrate_to_mariadb.py
```
Or pass the URL as a positional argument:
```bash
python migrate_to_mariadb.py 'mysql+pymysql://battuser:strongpassword@localhost/batteries?charset=utf8mb4'
```
The script:
1. Creates all tables on MariaDB if they don't exist
2. Inserts all Device records (preserving primary keys)
3. Inserts all Battery records (preserving primary keys and foreign keys)
4. Resets `AUTO_INCREMENT` counters past the highest migrated ID
5. Prints a verification table comparing row counts
---
## 6. Verify row counts
The migration script prints a summary:
```
=== Verification ===
Table SQLite MariaDB OK?
--------------------------------------
device 5 5 OK
battery 40 40 OK
Migration complete. All row counts match.
```
If you see `MISMATCH`, **do not** decommission SQLite. Re-run the script after
investigating the error output.
You can also verify manually in the MariaDB shell:
```sql
SELECT COUNT(*) FROM device;
SELECT COUNT(*) FROM battery;
```
---
## 7. MariaDB configuration notes
| Setting | Recommended value |
|---|---|
| Character set | `utf8mb4` |
| Collation | `utf8mb4_unicode_ci` |
| `innodb_strict_mode` | `ON` (default in MariaDB 10.2+) |
| `sql_mode` | Include `STRICT_TRANS_TABLES` |
Set these in `/etc/mysql/mariadb.conf.d/50-server.cnf` (or equivalent):
```ini
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
```
The `?charset=utf8mb4` query parameter in the connection string ensures the
client connection also uses utf8mb4, regardless of the server default.
---
## 8. Decommission SQLite
Once you have confirmed the row counts match and the app is running correctly
against MariaDB:
```bash
# Optional: keep a backup
cp batteries.db batteries.db.bak
# Remove the SQLite file
rm batteries.db
```
The `DATABASE_URL` environment variable now fully controls which database is used —
no code changes were required.
+275
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
import os
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL",
"sqlite:///batteries.db",
)
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
SQLALCHEMY_TRACK_MODIFICATIONS = False
+129
View File
@@ -0,0 +1,129 @@
"""
Migrate data from SQLite to MariaDB using SQLAlchemy ORM only — no raw SQL
except for the AUTO_INCREMENT reset which requires a DDL statement.
Usage:
MARIADB_URL='mysql+pymysql://user:pass@host/dbname?charset=utf8mb4' \\
python migrate_to_mariadb.py
Or pass the URL as a CLI argument:
python migrate_to_mariadb.py 'mysql+pymysql://user:pass@host/dbname?charset=utf8mb4'
"""
import sys
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
import config
from models import Base, Battery, Device
def migrate(mariadb_url: str):
print("=== Battery Tracker: SQLite → MariaDB Migration ===\n")
# -- Engines --
sqlite_engine = create_engine(config.SQLALCHEMY_DATABASE_URI)
mariadb_engine = create_engine(mariadb_url, pool_pre_ping=True)
SrcSession = sessionmaker(bind=sqlite_engine)
DstSession = sessionmaker(bind=mariadb_engine)
src = SrcSession()
dst = DstSession()
try:
# -- Create tables on MariaDB --
print("Creating tables on MariaDB (if not exist)…")
Base.metadata.create_all(mariadb_engine)
# -- Read source data --
src_devices = src.query(Device).all()
src_batteries = src.query(Battery).all()
print(f"Source: {len(src_devices)} devices, {len(src_batteries)} batteries\n")
# -- Migrate Devices first (batteries have FK → device) --
print("Migrating devices…")
for d in src_devices:
new_d = Device(
id=d.id,
name=d.name,
battery_slots=d.battery_slots,
notes=d.notes,
)
dst.add(new_d)
dst.flush()
# -- Migrate Batteries --
print("Migrating batteries…")
for b in src_batteries:
new_b = Battery(
id=b.id,
label=b.label,
brand=b.brand,
status=b.status,
device_id=b.device_id,
notes=b.notes,
)
dst.add(new_b)
dst.flush()
dst.commit()
print("Commit successful.\n")
# -- Reset AUTO_INCREMENT so new rows don't collide --
# This DDL is required; there is no ORM-level equivalent.
with mariadb_engine.connect() as conn:
max_device_id = max((d.id for d in src_devices), default=0)
max_battery_id = max((b.id for b in src_batteries), default=0)
conn.execute(text("ALTER TABLE device SET AUTO_INCREMENT = :v"),
{"v": max_device_id + 1})
conn.execute(text("ALTER TABLE battery SET AUTO_INCREMENT = :v"),
{"v": max_battery_id + 1})
conn.commit()
print("AUTO_INCREMENT counters reset.\n")
# -- Verify counts --
dst_device_count = dst.query(Device).count()
dst_battery_count = dst.query(Battery).count()
src_device_count = len(src_devices)
src_battery_count = len(src_batteries)
print("=== Verification ===")
print(f"{'Table':<12} {'SQLite':>8} {'MariaDB':>9} {'OK?':>5}")
print("-" * 38)
device_ok = src_device_count == dst_device_count
battery_ok = src_battery_count == dst_battery_count
print(f"{'device':<12} {src_device_count:>8} {dst_device_count:>9} {'OK' if device_ok else 'MISMATCH':>5}")
print(f"{'battery':<12} {src_battery_count:>8} {dst_battery_count:>9} {'OK' if battery_ok else 'MISMATCH':>5}")
if not (device_ok and battery_ok):
print("\nERROR: Row count mismatch. Do not decommission SQLite.")
sys.exit(1)
print("\nMigration complete. All row counts match.")
except Exception as exc:
dst.rollback()
print(f"\nERROR: {exc}")
raise
finally:
src.close()
dst.close()
if __name__ == "__main__":
if len(sys.argv) > 1:
url = sys.argv[1]
else:
import os
url = os.environ.get("MARIADB_URL")
if not url:
print("ERROR: Provide MariaDB URL via MARIADB_URL env var or as a CLI argument.")
print(" Example: python migrate_to_mariadb.py 'mysql+pymysql://user:pass@host/db?charset=utf8mb4'")
sys.exit(1)
migrate(url)
+52
View File
@@ -0,0 +1,52 @@
from sqlalchemy import Column, Integer, String, Text, ForeignKey
from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base()
class Device(Base):
__tablename__ = "device"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, unique=True)
battery_slots = Column(Integer, nullable=False, default=1)
notes = Column(Text, nullable=True)
batteries = relationship("Battery", back_populates="device")
def installed_count(self):
return sum(1 for b in self.batteries if b.status == "installed")
def installed_brands(self):
return set(b.brand for b in self.batteries if b.status == "installed")
def has_mixed_brands(self):
return len(self.installed_brands()) > 1
def __repr__(self):
return f"<Device {self.name}>"
class Battery(Base):
__tablename__ = "battery"
id = Column(Integer, primary_key=True, autoincrement=True)
label = Column(String(50), nullable=False, unique=True)
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)
notes = Column(Text, nullable=True)
device = relationship("Device", back_populates="batteries")
def is_available(self):
return self.status == "available"
def is_retired(self):
return self.status == "retired"
def is_installed(self):
return self.status == "installed"
def __repr__(self):
return f"<Battery {self.label}>"
+5
View File
@@ -0,0 +1,5 @@
Flask>=3.0,<4.0
SQLAlchemy>=2.0,<3.0
PyMySQL>=1.1,<2.0
waitress>=3.0,<4.0
pytest>=8.0,<9.0
+43
View File
@@ -0,0 +1,43 @@
# sbin — Support Scripts
## install-service.sh
Generates a systemd **user-level** service file and enables it. No root required.
**Prerequisites:** The app's `.venv` must exist and have Flask installed.
```bash
bash sbin/install-service.sh
```
You will be prompted for a host (default `127.0.0.1`) and port (default `5000`).
The script writes `~/.config/systemd/user/battery-tracker.service` and enables it.
**Start the service:**
```bash
systemctl --user start battery-tracker
systemctl --user status battery-tracker
```
**View live logs:**
```bash
journalctl --user -u battery-tracker -f
```
**Make it start on login** (requires lingering to be enabled for your user):
```bash
loginctl enable-linger "$USER"
```
**Uninstall:**
```bash
systemctl --user disable --now battery-tracker
rm ~/.config/systemd/user/battery-tracker.service
systemctl --user daemon-reload
```
**Re-run to update** (e.g. if you moved the app directory):
```bash
bash sbin/install-service.sh
systemctl --user restart battery-tracker
```
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# install-service.sh
# Generates and installs a systemd user-level service for the Battery Tracker app.
# No root required — uses ~/.config/systemd/user/.
#
# Usage: bash sbin/install-service.sh
set -euo pipefail
# Resolve the app root directory from the script's own location
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENV_WAITRESS="$APP_DIR/.venv/bin/waitress-serve"
SERVICE_DIR="$HOME/.config/systemd/user"
SERVICE_FILE="$SERVICE_DIR/battery-tracker.service"
echo "=== Battery Tracker — systemd user service installer ==="
echo "App directory: $APP_DIR"
echo
# Sanity checks
if [[ ! -f "$VENV_WAITRESS" ]]; then
echo "ERROR: waitress-serve not found at $VENV_WAITRESS"
echo "Run: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"
exit 1
fi
if [[ ! -f "$APP_DIR/app.py" ]]; then
echo "ERROR: app.py not found in $APP_DIR"
exit 1
fi
# Prompt for port and host
read -rp "Listen host [default: 127.0.0.1]: " HOST
HOST="${HOST:-127.0.0.1}"
read -rp "Listen port [default: 5000]: " PORT
PORT="${PORT:-5000}"
echo
echo "Generating service file → $SERVICE_FILE"
mkdir -p "$SERVICE_DIR"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=Battery Tracker Flask App
After=network.target
[Service]
Type=simple
WorkingDirectory=$APP_DIR
ExecStart=$VENV_WAITRESS --host=$HOST --port=$PORT app:app
Environment=PYTHONPATH=$APP_DIR
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
EOF
echo "Service file written."
echo
# Reload and enable
systemctl --user daemon-reload
systemctl --user enable battery-tracker
echo
echo "=== Done ==="
echo
echo "Start the service: systemctl --user start battery-tracker"
echo "Check status: systemctl --user status battery-tracker"
echo "View logs: journalctl --user -u battery-tracker -f"
echo "Stop the service: systemctl --user stop battery-tracker"
echo
echo "To uninstall:"
echo " systemctl --user disable --now battery-tracker"
echo " rm $SERVICE_FILE"
echo " systemctl --user daemon-reload"
+57
View File
@@ -0,0 +1,57 @@
"""
Seed the database with initial batteries and devices.
Safe to run multiple times — exits early if data already exists.
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import config
from models import Base, Battery, Device
def seed():
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
db = Session()
if db.query(Battery).count() > 0:
print("Database already seeded — skipping.")
db.close()
return
# --- Devices ---
device_specs = [
("RC Car", 4),
("TV Remote", 2),
("Flashlight", 3),
("Kids Toy 1", 3),
("Kids Toy 2", 2),
]
devices = []
for name, slots in device_specs:
d = Device(name=name, battery_slots=slots)
db.add(d)
devices.append(d)
# --- Batteries ---
battery_specs = [
("ENL", "Panasonic Eneloop", 16),
("BON", "BONAI", 16),
("ENR", "Energizer NiMH", 8),
]
for prefix, brand, count in battery_specs:
for i in range(1, count + 1):
label = f"{prefix}-{i:02d}"
db.add(Battery(label=label, brand=brand, status="available"))
db.commit()
db.close()
total = sum(c for _, _, c in battery_specs)
print(f"Seeded {len(device_specs)} devices and {total} batteries.")
if __name__ == "__main__":
seed()
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Assign {{ battery.label }} — Battery Tracker{% endblock %}
{% block content %}
<h1>Assign {{ battery.label }}</h1>
<p class="text-muted" style="margin-bottom:1rem;">Brand: {{ battery.brand }}</p>
<div class="card">
{% if devices %}
<form method="post" action="{{ url_for('battery_assign', battery_id=battery.id) }}">
<div class="form-group">
<label>Select Device</label>
{% for device in devices %}
{% set full = device.installed_count() >= device.battery_slots %}
{% set mix = device.installed_brands() and battery.brand not in device.installed_brands() %}
<div style="margin-bottom:0.75rem;padding:0.6rem 0.75rem;border:1px solid #e2e8f0;border-radius:4px;
{% if full %}opacity:0.5;{% endif %}background:#fff;">
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
<input type="radio" name="device_id" value="{{ device.id }}"
{% if full %}disabled{% endif %}
style="cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
<span>
<strong>{{ device.name }}</strong>
<span class="text-muted">({{ device.installed_count() }}/{{ device.battery_slots }} slots used)</span>
{% if full %}
<span class="badge badge-retired">Full</span>
{% endif %}
</span>
</label>
{% if mix and not full %}
<p class="text-warning" style="margin-top:0.3rem;margin-left:1.6rem;">
⚠ Already has {{ device.installed_brands()|join(', ') }} — mixing brands not recommended.
</p>
{% endif %}
</div>
{% endfor %}
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Assign Battery</button>
<a class="btn btn-secondary" href="{{ url_for('battery_detail', battery_id=battery.id) }}">Cancel</a>
</div>
</form>
{% else %}
<p class="text-muted">No devices exist yet. <a href="{{ url_for('device_add') }}">Add a device first.</a></p>
{% endif %}
</div>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Battery Tracker{% endblock %}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 15px; background: #f5f5f5; color: #222; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
/* Nav */
nav {
background: #1e40af;
color: #fff;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding: 0.75rem 1rem;
}
nav .brand { font-weight: 700; font-size: 1.1rem; margin-right: auto; color: #fff; }
nav a { color: #bfdbfe; font-size: 0.9rem; padding: 0.25rem 0.5rem; border-radius: 4px; }
nav a:hover { background: #1d4ed8; color: #fff; text-decoration: none; }
/* Layout */
.container { max-width: 960px; margin: 1.5rem auto; padding: 0 1rem; }
/* Flash messages */
.flash { padding: 0.6rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem; }
.flash.success { background: #dcfce7; color: #166534; border: 1px solid #86efac; }
.flash.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.flash.warning { background: #fef9c3; color: #854d0e; border: 1px solid #fde047; }
/* Cards / boxes */
.card { background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,.1); padding: 1.25rem; margin-bottom: 1rem; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 0.5rem 0.75rem; background: #f1f5f9; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; color: #64748b; border-bottom: 2px solid #e2e8f0; }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #e2e8f0; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8fafc; }
/* Status badges */
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
.badge-available { background: #dcfce7; color: #166534; }
.badge-installed { background: #dbeafe; color: #1e40af; }
.badge-retired { background: #f1f5f9; color: #64748b; }
.badge-warning { background: #fef9c3; color: #854d0e; }
/* Buttons */
.btn { display: inline-block; padding: 0.4rem 0.9rem; border-radius: 4px; border: none; cursor: pointer; font-size: 0.875rem; font-family: inherit; text-decoration: none; }
.btn:hover { text-decoration: none; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-danger:hover { background: #b91c1c; }
.btn-warning { background: #d97706; color: #fff; }
.btn-warning:hover { background: #b45309; }
.btn-secondary { background: #e2e8f0; color: #334155; }
.btn-secondary:hover { background: #cbd5e1; }
.btn-sm { padding: 0.25rem 0.6rem; font-size: 0.8rem; }
/* Forms */
.form-group { margin-bottom: 1rem; }
label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 0.3rem; color: #374151; }
input[type=text], input[type=number], select, textarea {
width: 100%; padding: 0.45rem 0.65rem; border: 1px solid #d1d5db;
border-radius: 4px; font-size: 0.9rem; font-family: inherit;
}
input:focus, select:focus, textarea:focus { outline: 2px solid #3b82f6; border-color: #3b82f6; }
textarea { min-height: 80px; resize: vertical; }
.form-actions { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; margin-top: 1.25rem; }
/* Headings */
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #1e293b; }
h2 { font-size: 1.15rem; margin-bottom: 0.75rem; color: #334155; }
/* Inline form (for POST buttons in tables) */
form.inline { display: inline; }
/* Warning text */
.text-warning { color: #b45309; font-size: 0.8rem; }
.text-danger { color: #dc2626; }
.text-muted { color: #6b7280; font-size: 0.85rem; }
</style>
</head>
<body>
<nav>
<span class="brand">Battery Tracker</span>
<a href="{{ url_for('dashboard') }}">Dashboard</a>
<a href="{{ url_for('device_list') }}">Devices</a>
<a href="{{ url_for('battery_add') }}">+ Battery</a>
<a href="{{ url_for('device_add') }}">+ Device</a>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Add Battery — Battery Tracker{% endblock %}
{% block content %}
<h1>Add Battery</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('') }}"
placeholder="e.g. Panasonic Eneloop" required>
</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>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" placeholder="Optional notes…">{{ form_notes|default('') }}</textarea>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Add Battery</button>
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}">Cancel</a>
</div>
</form>
</div>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Delete {{ battery.label }} — Battery Tracker{% endblock %}
{% block content %}
<h1>Delete Battery</h1>
<div class="card">
<p style="margin-bottom:1rem;">
You are about to <strong>permanently delete</strong> battery
<strong>{{ battery.label }}</strong> ({{ battery.brand }}).
This action cannot be undone.
</p>
{% if battery.is_installed() %}
<p class="text-warning" style="margin-bottom:1rem;">
⚠ This battery is currently installed in
<strong>{{ battery.device.name }}</strong>.
Deleting it will free that slot.
</p>
{% endif %}
<div class="form-actions">
<form method="post" action="{{ url_for('battery_delete', battery_id=battery.id) }}">
<button class="btn btn-danger" type="submit">Confirm Delete</button>
</form>
<a class="btn btn-secondary" href="{{ url_for('battery_detail', battery_id=battery.id) }}">Cancel</a>
</div>
</div>
{% endblock %}
+70
View File
@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}{{ battery.label }} — Battery Tracker{% endblock %}
{% block content %}
<h1>{{ battery.label }}</h1>
<div class="card">
<table style="width:auto;border:none;">
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Label</td>
<td style="border:none;">{{ battery.label }}</td>
</tr>
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Brand</td>
<td style="border:none;">{{ battery.brand }}</td>
</tr>
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Status</td>
<td style="border:none;"><span class="badge badge-{{ battery.status }}">{{ battery.status|capitalize }}</span></td>
</tr>
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Device</td>
<td style="border:none;">
{% if battery.device %}
<a href="{{ url_for('device_detail', device_id=battery.device.id) }}">{{ battery.device.name }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
</table>
</div>
<!-- Notes -->
<div class="card">
<h2>Notes</h2>
<form method="post" action="{{ url_for('battery_edit_notes', battery_id=battery.id) }}">
<div class="form-group">
<textarea name="notes" placeholder="No notes yet…">{{ battery.notes or '' }}</textarea>
</div>
<button class="btn btn-primary" type="submit">Save Notes</button>
</form>
</div>
<!-- Actions -->
<div class="card">
<h2>Actions</h2>
<div class="form-actions">
{% if battery.is_available() %}
<a class="btn btn-primary" href="{{ url_for('battery_assign', battery_id=battery.id) }}">Assign to Device</a>
{% endif %}
{% if battery.is_installed() %}
<form method="post" action="{{ url_for('battery_unassign', battery_id=battery.id) }}">
<button class="btn btn-warning" type="submit">Unassign</button>
</form>
{% endif %}
{% if not battery.is_retired() %}
<form method="post" action="{{ url_for('battery_retire', battery_id=battery.id) }}">
<button class="btn btn-secondary" type="submit">Retire Battery</button>
</form>
{% endif %}
<a class="btn btn-danger" href="{{ url_for('battery_delete', battery_id=battery.id) }}">Delete Battery</a>
</div>
</div>
<a class="text-muted" href="{{ url_for('dashboard') }}">&larr; Back to Dashboard</a>
{% endblock %}
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}Dashboard — Battery Tracker{% endblock %}
{% block content %}
<h1>Battery Dashboard</h1>
{% set total = batteries|length %}
{% set available = batteries|selectattr('status','eq','available')|list|length %}
{% set installed = batteries|selectattr('status','eq','installed')|list|length %}
{% set retired = batteries|selectattr('status','eq','retired')|list|length %}
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
<div class="card" style="flex:1;min-width:120px;text-align:center;">
<div style="font-size:1.8rem;font-weight:700;">{{ total }}</div>
<div class="text-muted">Total</div>
</div>
<div class="card" style="flex:1;min-width:120px;text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:#166534;">{{ available }}</div>
<div class="text-muted">Available</div>
</div>
<div class="card" style="flex:1;min-width:120px;text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:#1e40af;">{{ installed }}</div>
<div class="text-muted">Installed</div>
</div>
<div class="card" style="flex:1;min-width:120px;text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:#64748b;">{{ retired }}</div>
<div class="text-muted">Retired</div>
</div>
</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>
{% 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>
{% 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 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>
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Add Device — Battery Tracker{% endblock %}
{% block content %}
<h1>Add Device</h1>
<div class="card">
<form method="post" action="{{ url_for('device_add') }}">
<div class="form-group">
<label for="name">Device Name <span class="text-danger">*</span></label>
<input type="text" id="name" name="name" value="{{ form_name|default('') }}"
placeholder="e.g. Game Controller" required>
</div>
<div class="form-group">
<label for="battery_slots">Battery Slots <span class="text-danger">*</span></label>
<input type="number" id="battery_slots" name="battery_slots"
value="{{ form_slots|default(1) }}" min="1" required>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" placeholder="Optional notes…">{{ form_notes|default('') }}</textarea>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Add Device</button>
<a class="btn btn-secondary" href="{{ url_for('device_list') }}">Cancel</a>
</div>
</form>
</div>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}{{ device.name }} — Battery Tracker{% endblock %}
{% block content %}
<h1>{{ device.name }}</h1>
<div class="card">
<table style="width:auto;border:none;">
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Slots</td>
<td style="border:none;">{{ device.installed_count() }} / {{ device.battery_slots }} used</td>
</tr>
{% if device.has_mixed_brands() %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Warning</td>
<td style="border:none;"><span class="badge badge-warning">⚠ Mixed brands installed</span></td>
</tr>
{% endif %}
{% if device.notes %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
<td style="border:none;">{{ device.notes }}</td>
</tr>
{% endif %}
</table>
</div>
<div class="card">
<h2>Installed Batteries</h2>
{% set installed = device.batteries | selectattr('status', 'eq', 'installed') | list %}
{% if installed %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Label</th><th>Brand</th><th>Notes</th><th>Actions</th></tr>
</thead>
<tbody>
{% for b in installed %}
<tr>
<td><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
<td>{{ b.brand }}</td>
<td class="text-muted">{{ b.notes or '—' }}</td>
<td>
<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>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No batteries installed.</p>
{% endif %}
</div>
<div class="card">
<h2>Delete Device</h2>
<p style="margin-bottom:1rem;" class="text-muted">
Deleting this device will unassign all installed batteries and mark them available.
</p>
<form method="post" action="{{ url_for('device_delete', device_id=device.id) }}">
<button class="btn btn-danger" type="submit">Delete {{ device.name }}</button>
</form>
</div>
<a class="text-muted" href="{{ url_for('device_list') }}">&larr; Back to Devices</a>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Devices — Battery Tracker{% endblock %}
{% block content %}
<h1>Devices</h1>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Slots</th>
<th>Installed</th>
<th>Brands</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<td><a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a></td>
<td>{{ d.battery_slots }}</td>
<td>
{{ d.installed_count() }} / {{ d.battery_slots }}
{% if d.installed_count() >= d.battery_slots %}
<span class="badge badge-retired">Full</span>
{% endif %}
</td>
<td>
{% set brands = d.installed_brands() %}
{% if brands %}
{{ brands|join(', ') }}
{% if d.has_mixed_brands() %}
<span class="badge badge-warning">⚠ mixed</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('device_detail', device_id=d.id) }}">View</a>
<form class="inline" method="post" action="{{ url_for('device_delete', device_id=d.id) }}"
onsubmit="return confirm('Delete {{ d.name }}? All installed batteries will be unassigned.');">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-muted" style="text-align:center;padding:1rem;">No devices yet. <a href="{{ url_for('device_add') }}">Add one.</a></td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<a class="btn btn-primary" href="{{ url_for('device_add') }}">+ Add Device</a>
{% endblock %}
View File
+41
View File
@@ -0,0 +1,41 @@
import os
import tempfile
import pytest
@pytest.fixture()
def app():
"""Create app with a temporary file-based SQLite DB, removed after the test."""
db_fd, db_path = tempfile.mkstemp(suffix=".db")
os.close(db_fd)
class TestConfig:
SQLALCHEMY_DATABASE_URI = f"sqlite:///{db_path}"
SECRET_KEY = "test-secret"
SQLALCHEMY_TRACK_MODIFICATIONS = False
TESTING = True
from app import create_app
flask_app = create_app(TestConfig)
yield flask_app
os.unlink(db_path)
@pytest.fixture()
def client(app):
return app.test_client()
@pytest.fixture()
def seeded_client(app):
"""Test client pre-loaded with 2 devices and 3 batteries (2 available, 1 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"})
yield c
+281
View File
@@ -0,0 +1,281 @@
"""
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)
"""
import pytest
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
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
return loc
def follow(client, resp):
"""Follow a single redirect."""
return client.get(get_location(resp))
# ------------------------------------------------------------------ #
# Dashboard
# ------------------------------------------------------------------ #
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
# ------------------------------------------------------------------ #
# Battery — add
# ------------------------------------------------------------------ #
def test_add_battery(client):
resp = client.post("/battery/add",
data={"label": "NEW-01", "brand": "TestBrand", "status": "available"},
follow_redirects=True)
assert resp.status_code == 200
assert b"NEW-01" 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"})
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
# ------------------------------------------------------------------ #
# Battery — detail
# ------------------------------------------------------------------ #
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" in resp.data
def test_battery_detail_not_found(client):
resp = client.get("/battery/9999")
assert resp.status_code == 404
# ------------------------------------------------------------------ #
# Battery — edit notes
# ------------------------------------------------------------------ #
def test_edit_notes(seeded_client):
seeded_client.post("/battery/1/edit-notes", data={"notes": "test note here"})
resp = seeded_client.get("/battery/1")
assert b"test note here" in resp.data
# ------------------------------------------------------------------ #
# Battery — assign
# ------------------------------------------------------------------ #
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)
assert resp.status_code == 200
assert b"Device A" in resp.data
def test_assign_retired_battery_blocked(seeded_client):
# TST-03 is retired (id=3)
resp = seeded_client.post("/battery/3/assign",
data={"device_id": "1"},
follow_redirects=True)
assert resp.status_code == 200
assert b"retired" in resp.data.lower()
def test_assign_over_capacity_blocked(seeded_client):
# Fill Device B (1 slot) with TST-01
seeded_client.post("/battery/1/assign", data={"device_id": "2"})
# Try to assign TST-02 to the same full device
resp = seeded_client.post("/battery/2/assign",
data={"device_id": "2"},
follow_redirects=True)
assert resp.status_code == 200
assert b"full" in resp.data.lower()
def test_brand_mix_warning(seeded_client):
# Assign TST-01 (BrandX) 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
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()
# ------------------------------------------------------------------ #
# Battery — unassign
# ------------------------------------------------------------------ #
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()
# ------------------------------------------------------------------ #
# Battery — retire
# ------------------------------------------------------------------ #
def test_retire_battery(seeded_client):
resp = seeded_client.post("/battery/1/retire", follow_redirects=True)
assert resp.status_code == 200
resp2 = seeded_client.get("/battery/1")
assert b"retired" in resp2.data.lower()
def test_retire_clears_device(seeded_client):
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
seeded_client.post("/battery/1/retire")
resp = seeded_client.get("/battery/1")
assert b"None" in resp.data or b"retired" in resp.data.lower()
def test_retire_already_retired(seeded_client):
# TST-03 is already retired (id=3)
resp = seeded_client.post("/battery/3/retire", follow_redirects=True)
assert resp.status_code == 200
assert b"already retired" in resp.data.lower()
# ------------------------------------------------------------------ #
# Battery — delete
# ------------------------------------------------------------------ #
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
def test_delete_battery(seeded_client):
seeded_client.post("/battery/1/delete")
resp = seeded_client.get("/battery/1")
assert resp.status_code == 404
# ------------------------------------------------------------------ #
# Device — list
# ------------------------------------------------------------------ #
def test_device_list(seeded_client):
resp = seeded_client.get("/device/")
assert resp.status_code == 200
assert b"Device A" in resp.data
assert b"Device B" in resp.data
# ------------------------------------------------------------------ #
# Device — add
# ------------------------------------------------------------------ #
def test_add_device(client):
resp = client.post("/device/add",
data={"name": "My Gadget", "battery_slots": "3"},
follow_redirects=True)
assert resp.status_code == 200
assert b"My Gadget" in resp.data
def test_add_device_duplicate_name(seeded_client):
resp = seeded_client.post("/device/add",
data={"name": "Device A", "battery_slots": "2"})
assert resp.status_code == 400
assert b"already exists" in resp.data
def test_add_device_missing_name(client):
resp = client.post("/device/add", data={"name": "", "battery_slots": "1"})
assert resp.status_code == 400
# ------------------------------------------------------------------ #
# Device — detail
# ------------------------------------------------------------------ #
def test_device_detail(seeded_client):
resp = seeded_client.get("/device/1")
assert resp.status_code == 200
assert b"Device A" in resp.data
assert b"2" in resp.data # battery_slots
def test_device_detail_not_found(client):
resp = client.get("/device/9999")
assert resp.status_code == 404
# ------------------------------------------------------------------ #
# Device — delete
# ------------------------------------------------------------------ #
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()
def test_delete_device_removed(seeded_client):
seeded_client.post("/device/1/delete")
resp = seeded_client.get("/device/1")
assert resp.status_code == 404
# ------------------------------------------------------------------ #
# Full round-trip
# ------------------------------------------------------------------ #
def test_add_assign_delete_battery(client):
# Add a device
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)
assert b"Gadget" in resp.data
# Delete it
client.post("/battery/1/delete")
assert client.get("/battery/1").status_code == 404