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:
+10
@@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
instance/
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
+164
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}>"
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
Executable
+79
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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') }}">← Back to Dashboard</a>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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') }}">← Back to Devices</a>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user