From 6ea3eae981b15be7e87f7a64582818390e648cd7 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Sat, 11 Apr 2026 22:38:16 -0500 Subject: [PATCH] Initial commit: Flask battery tracker app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 10 ++ MIGRATION.md | 164 ++++++++++++++++++++ app.py | 275 +++++++++++++++++++++++++++++++++ config.py | 8 + migrate_to_mariadb.py | 129 ++++++++++++++++ models.py | 52 +++++++ requirements.txt | 5 + sbin/README.md | 43 ++++++ sbin/install-service.sh | 79 ++++++++++ seed.py | 57 +++++++ templates/assign.html | 48 ++++++ templates/base.html | 110 +++++++++++++ templates/battery_add.html | 41 +++++ templates/battery_delete.html | 29 ++++ templates/battery_detail.html | 70 +++++++++ templates/dashboard.html | 88 +++++++++++ templates/device_add.html | 32 ++++ templates/device_detail.html | 69 +++++++++ templates/device_list.html | 58 +++++++ tests/__init__.py | 0 tests/conftest.py | 41 +++++ tests/test_acceptance.py | 281 ++++++++++++++++++++++++++++++++++ 22 files changed, 1689 insertions(+) create mode 100644 .gitignore create mode 100644 MIGRATION.md create mode 100644 app.py create mode 100644 config.py create mode 100644 migrate_to_mariadb.py create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 sbin/README.md create mode 100755 sbin/install-service.sh create mode 100644 seed.py create mode 100644 templates/assign.html create mode 100644 templates/base.html create mode 100644 templates/battery_add.html create mode 100644 templates/battery_delete.html create mode 100644 templates/battery_detail.html create mode 100644 templates/dashboard.html create mode 100644 templates/device_add.html create mode 100644 templates/device_detail.html create mode 100644 templates/device_list.html create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_acceptance.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67afd6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.pyc +*.db +*.db-shm +*.db-wal +instance/ +.pytest_cache/ +*.egg-info/ +dist/ diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..64e7b2b --- /dev/null +++ b/MIGRATION.md @@ -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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..4c720fb --- /dev/null +++ b/app.py @@ -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/") + 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//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//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//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//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//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/") + 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//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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..ef5ec42 --- /dev/null +++ b/config.py @@ -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 diff --git a/migrate_to_mariadb.py b/migrate_to_mariadb.py new file mode 100644 index 0000000..74956dd --- /dev/null +++ b/migrate_to_mariadb.py @@ -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) diff --git a/models.py b/models.py new file mode 100644 index 0000000..b846a0b --- /dev/null +++ b/models.py @@ -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"" + + +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"" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f21e109 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/sbin/README.md b/sbin/README.md new file mode 100644 index 0000000..b76d4cb --- /dev/null +++ b/sbin/README.md @@ -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 +``` diff --git a/sbin/install-service.sh b/sbin/install-service.sh new file mode 100755 index 0000000..c042aa4 --- /dev/null +++ b/sbin/install-service.sh @@ -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" < 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() diff --git a/templates/assign.html b/templates/assign.html new file mode 100644 index 0000000..915807a --- /dev/null +++ b/templates/assign.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Assign {{ battery.label }} — Battery Tracker{% endblock %} + +{% block content %} +

Assign {{ battery.label }}

+

Brand: {{ battery.brand }}

+ +
+ {% if devices %} +
+
+ + {% 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() %} +
+ + {% if mix and not full %} +

+ ⚠ Already has {{ device.installed_brands()|join(', ') }} — mixing brands not recommended. +

+ {% endif %} +
+ {% endfor %} +
+ +
+ + Cancel +
+
+ {% else %} +

No devices exist yet. Add a device first.

+ {% endif %} +
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..43a3660 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,110 @@ + + + + + + {% block title %}Battery Tracker{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + diff --git a/templates/battery_add.html b/templates/battery_add.html new file mode 100644 index 0000000..bbd9e58 --- /dev/null +++ b/templates/battery_add.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Add Battery — Battery Tracker{% endblock %} + +{% block content %} +

Add Battery

+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/templates/battery_delete.html b/templates/battery_delete.html new file mode 100644 index 0000000..5eb3d00 --- /dev/null +++ b/templates/battery_delete.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Delete {{ battery.label }} — Battery Tracker{% endblock %} + +{% block content %} +

Delete Battery

+ +
+

+ You are about to permanently delete battery + {{ battery.label }} ({{ battery.brand }}). + This action cannot be undone. +

+ + {% if battery.is_installed() %} +

+ ⚠ This battery is currently installed in + {{ battery.device.name }}. + Deleting it will free that slot. +

+ {% endif %} + +
+
+ +
+ Cancel +
+
+{% endblock %} diff --git a/templates/battery_detail.html b/templates/battery_detail.html new file mode 100644 index 0000000..47b2d8f --- /dev/null +++ b/templates/battery_detail.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block title %}{{ battery.label }} — Battery Tracker{% endblock %} + +{% block content %} +

{{ battery.label }}

+ +
+ + + + + + + + + + + + + + + + + +
Label{{ battery.label }}
Brand{{ battery.brand }}
Status{{ battery.status|capitalize }}
Device + {% if battery.device %} + {{ battery.device.name }} + {% else %} + None + {% endif %} +
+
+ + +
+

Notes

+
+
+ +
+ +
+
+ + +
+

Actions

+
+ {% if battery.is_available() %} + Assign to Device + {% endif %} + + {% if battery.is_installed() %} +
+ +
+ {% endif %} + + {% if not battery.is_retired() %} +
+ +
+ {% endif %} + + Delete Battery +
+
+ +← Back to Dashboard +{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..74f80d3 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% block title %}Dashboard — Battery Tracker{% endblock %} + +{% block content %} +

Battery Dashboard

+ +{% 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 %} + +
+
+
{{ total }}
+
Total
+
+
+
{{ available }}
+
Available
+
+
+
{{ installed }}
+
Installed
+
+
+
{{ retired }}
+
Retired
+
+
+ +
+
+ + + + + + + + + + + + {% for b in batteries %} + + + + + + + + {% else %} + + {% endfor %} + +
LabelBrandStatusAssigned ToActions
{{ b.label }}{{ b.brand }} + {{ b.status|capitalize }} + + {% if b.device %} + {{ b.device.name }} + {% if b.device.has_mixed_brands() %} + ⚠ mixed + {% endif %} + {% else %} + + {% endif %} + + View + + {% if b.is_available() %} + Assign + {% endif %} + + {% if b.is_installed() %} +
+ +
+ {% endif %} + + {% if not b.is_retired() %} +
+ +
+ {% endif %} +
No batteries found. Add one.
+
+
+{% endblock %} diff --git a/templates/device_add.html b/templates/device_add.html new file mode 100644 index 0000000..e07ece5 --- /dev/null +++ b/templates/device_add.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Add Device — Battery Tracker{% endblock %} + +{% block content %} +

Add Device

+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/templates/device_detail.html b/templates/device_detail.html new file mode 100644 index 0000000..c263478 --- /dev/null +++ b/templates/device_detail.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}{{ device.name }} — Battery Tracker{% endblock %} + +{% block content %} +

{{ device.name }}

+ +
+ + + + + + {% if device.has_mixed_brands() %} + + + + + {% endif %} + {% if device.notes %} + + + + + {% endif %} +
Slots{{ device.installed_count() }} / {{ device.battery_slots }} used
Warning⚠ Mixed brands installed
Notes{{ device.notes }}
+
+ +
+

Installed Batteries

+ {% set installed = device.batteries | selectattr('status', 'eq', 'installed') | list %} + {% if installed %} +
+ + + + + + {% for b in installed %} + + + + + + + {% endfor %} + +
LabelBrandNotesActions
{{ b.label }}{{ b.brand }}{{ b.notes or '—' }} +
+ +
+
+
+ {% else %} +

No batteries installed.

+ {% endif %} +
+ +
+

Delete Device

+

+ Deleting this device will unassign all installed batteries and mark them available. +

+
+ +
+
+ +← Back to Devices +{% endblock %} diff --git a/templates/device_list.html b/templates/device_list.html new file mode 100644 index 0000000..ba77851 --- /dev/null +++ b/templates/device_list.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}Devices — Battery Tracker{% endblock %} + +{% block content %} +

Devices

+ +
+
+ + + + + + + + + + + + {% for d in devices %} + + + + + + + + {% else %} + + {% endfor %} + +
NameSlotsInstalledBrandsActions
{{ d.name }}{{ d.battery_slots }} + {{ d.installed_count() }} / {{ d.battery_slots }} + {% if d.installed_count() >= d.battery_slots %} + Full + {% endif %} + + {% set brands = d.installed_brands() %} + {% if brands %} + {{ brands|join(', ') }} + {% if d.has_mixed_brands() %} + ⚠ mixed + {% endif %} + {% else %} + + {% endif %} + + View +
+ +
+
No devices yet. Add one.
+
+
+ ++ Add Device +{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..76fd09f --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py new file mode 100644 index 0000000..4e10b6a --- /dev/null +++ b/tests/test_acceptance.py @@ -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