Initial commit: Flask battery tracker app

- Flask + SQLAlchemy (MariaDB-compatible schema) battery tracking web app
- 40 pre-seeded batteries (Eneloop, BONAI, Energizer NiMH) across 5 devices
- Business rules: block retired assignment, brand-mix warnings, capacity checks
- Mobile-friendly Jinja2 templates with inline CSS
- waitress WSGI server via systemd user service (sbin/install-service.sh)
- SQLite → MariaDB migration script (migrate_to_mariadb.py)
- 26 passing acceptance tests (pytest + Flask test client)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 22:38:16 -05:00
commit 6ea3eae981
22 changed files with 1689 additions and 0 deletions
View File
+41
View File
@@ -0,0 +1,41 @@
import os
import tempfile
import pytest
@pytest.fixture()
def app():
"""Create app with a temporary file-based SQLite DB, removed after the test."""
db_fd, db_path = tempfile.mkstemp(suffix=".db")
os.close(db_fd)
class TestConfig:
SQLALCHEMY_DATABASE_URI = f"sqlite:///{db_path}"
SECRET_KEY = "test-secret"
SQLALCHEMY_TRACK_MODIFICATIONS = False
TESTING = True
from app import create_app
flask_app = create_app(TestConfig)
yield flask_app
os.unlink(db_path)
@pytest.fixture()
def client(app):
return app.test_client()
@pytest.fixture()
def seeded_client(app):
"""Test client pre-loaded with 2 devices and 3 batteries (2 available, 1 retired)."""
with app.test_client() as c:
c.post("/device/add", data={"name": "Device A", "battery_slots": "2"})
c.post("/device/add", data={"name": "Device B", "battery_slots": "1"})
c.post("/battery/add", data={"label": "TST-01", "brand": "BrandX", "status": "available"})
c.post("/battery/add", data={"label": "TST-02", "brand": "BrandY", "status": "available"})
c.post("/battery/add", data={"label": "TST-03", "brand": "BrandX", "status": "retired"})
yield c
+281
View File
@@ -0,0 +1,281 @@
"""
Acceptance tests for Battery Tracker.
Each test gets a fresh in-memory SQLite database via the fixtures in conftest.py.
The `seeded_client` fixture pre-populates:
Devices: Device A (2 slots), Device B (1 slot)
Batteries: TST-01 (BrandX, available), TST-02 (BrandY, available), TST-03 (BrandX, retired)
"""
import pytest
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def get_location(resp):
"""Return the redirect Location header (without host)."""
loc = resp.headers.get("Location", "")
# Strip scheme+host if present
if loc.startswith("http"):
from urllib.parse import urlparse
loc = urlparse(loc).path
return loc
def follow(client, resp):
"""Follow a single redirect."""
return client.get(get_location(resp))
# ------------------------------------------------------------------ #
# Dashboard
# ------------------------------------------------------------------ #
def test_dashboard_loads(seeded_client):
resp = seeded_client.get("/")
assert resp.status_code == 200
assert b"TST-01" in resp.data
assert b"TST-02" in resp.data
assert b"TST-03" in resp.data
# ------------------------------------------------------------------ #
# Battery — add
# ------------------------------------------------------------------ #
def test_add_battery(client):
resp = client.post("/battery/add",
data={"label": "NEW-01", "brand": "TestBrand", "status": "available"},
follow_redirects=True)
assert resp.status_code == 200
assert b"NEW-01" in resp.data
def test_add_battery_duplicate_label(seeded_client):
resp = seeded_client.post("/battery/add",
data={"label": "TST-01", "brand": "OtherBrand", "status": "available"})
assert resp.status_code == 400
assert b"already exists" in resp.data
def test_add_battery_missing_fields(client):
resp = client.post("/battery/add", data={"label": "", "brand": ""})
assert resp.status_code == 400
assert b"required" in resp.data
# ------------------------------------------------------------------ #
# Battery — detail
# ------------------------------------------------------------------ #
def test_battery_detail(seeded_client):
resp = seeded_client.get("/battery/1")
assert resp.status_code == 200
assert b"TST-01" in resp.data
assert b"BrandX" in resp.data
def test_battery_detail_not_found(client):
resp = client.get("/battery/9999")
assert resp.status_code == 404
# ------------------------------------------------------------------ #
# Battery — edit notes
# ------------------------------------------------------------------ #
def test_edit_notes(seeded_client):
seeded_client.post("/battery/1/edit-notes", data={"notes": "test note here"})
resp = seeded_client.get("/battery/1")
assert b"test note here" in resp.data
# ------------------------------------------------------------------ #
# Battery — assign
# ------------------------------------------------------------------ #
def test_assign_battery(seeded_client):
# TST-01 is battery id=1, Device A is device id=1
resp = seeded_client.post("/battery/1/assign",
data={"device_id": "1"},
follow_redirects=True)
assert resp.status_code == 200
assert b"Device A" in resp.data
def test_assign_retired_battery_blocked(seeded_client):
# TST-03 is retired (id=3)
resp = seeded_client.post("/battery/3/assign",
data={"device_id": "1"},
follow_redirects=True)
assert resp.status_code == 200
assert b"retired" in resp.data.lower()
def test_assign_over_capacity_blocked(seeded_client):
# Fill Device B (1 slot) with TST-01
seeded_client.post("/battery/1/assign", data={"device_id": "2"})
# Try to assign TST-02 to the same full device
resp = seeded_client.post("/battery/2/assign",
data={"device_id": "2"},
follow_redirects=True)
assert resp.status_code == 200
assert b"full" in resp.data.lower()
def test_brand_mix_warning(seeded_client):
# Assign TST-01 (BrandX) to Device A
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
# Assign TST-02 (BrandY) to same Device A → brand mix warning, but succeeds
resp = seeded_client.post("/battery/2/assign",
data={"device_id": "1"},
follow_redirects=True)
assert resp.status_code == 200
# Warning flash should mention mixing
assert b"mixing" in resp.data.lower() or b"mix" in resp.data.lower() or b"brand" in resp.data.lower()
# ------------------------------------------------------------------ #
# Battery — unassign
# ------------------------------------------------------------------ #
def test_unassign_battery(seeded_client):
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
resp = seeded_client.post("/battery/1/unassign", follow_redirects=True)
assert resp.status_code == 200
# Battery should show as available on dashboard
resp2 = seeded_client.get("/battery/1")
assert b"available" in resp2.data.lower()
# ------------------------------------------------------------------ #
# Battery — retire
# ------------------------------------------------------------------ #
def test_retire_battery(seeded_client):
resp = seeded_client.post("/battery/1/retire", follow_redirects=True)
assert resp.status_code == 200
resp2 = seeded_client.get("/battery/1")
assert b"retired" in resp2.data.lower()
def test_retire_clears_device(seeded_client):
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
seeded_client.post("/battery/1/retire")
resp = seeded_client.get("/battery/1")
assert b"None" in resp.data or b"retired" in resp.data.lower()
def test_retire_already_retired(seeded_client):
# TST-03 is already retired (id=3)
resp = seeded_client.post("/battery/3/retire", follow_redirects=True)
assert resp.status_code == 200
assert b"already retired" in resp.data.lower()
# ------------------------------------------------------------------ #
# Battery — delete
# ------------------------------------------------------------------ #
def test_delete_battery_confirmation_page(seeded_client):
resp = seeded_client.get("/battery/1/delete")
assert resp.status_code == 200
assert b"Confirm Delete" in resp.data
assert b"TST-01" in resp.data
def test_delete_battery(seeded_client):
seeded_client.post("/battery/1/delete")
resp = seeded_client.get("/battery/1")
assert resp.status_code == 404
# ------------------------------------------------------------------ #
# Device — list
# ------------------------------------------------------------------ #
def test_device_list(seeded_client):
resp = seeded_client.get("/device/")
assert resp.status_code == 200
assert b"Device A" in resp.data
assert b"Device B" in resp.data
# ------------------------------------------------------------------ #
# Device — add
# ------------------------------------------------------------------ #
def test_add_device(client):
resp = client.post("/device/add",
data={"name": "My Gadget", "battery_slots": "3"},
follow_redirects=True)
assert resp.status_code == 200
assert b"My Gadget" in resp.data
def test_add_device_duplicate_name(seeded_client):
resp = seeded_client.post("/device/add",
data={"name": "Device A", "battery_slots": "2"})
assert resp.status_code == 400
assert b"already exists" in resp.data
def test_add_device_missing_name(client):
resp = client.post("/device/add", data={"name": "", "battery_slots": "1"})
assert resp.status_code == 400
# ------------------------------------------------------------------ #
# Device — detail
# ------------------------------------------------------------------ #
def test_device_detail(seeded_client):
resp = seeded_client.get("/device/1")
assert resp.status_code == 200
assert b"Device A" in resp.data
assert b"2" in resp.data # battery_slots
def test_device_detail_not_found(client):
resp = client.get("/device/9999")
assert resp.status_code == 404
# ------------------------------------------------------------------ #
# Device — delete
# ------------------------------------------------------------------ #
def test_delete_device_frees_batteries(seeded_client):
# Assign TST-01 to Device A
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
# Delete Device A
resp = seeded_client.post("/device/1/delete", follow_redirects=True)
assert resp.status_code == 200
# TST-01 should now be available
resp2 = seeded_client.get("/battery/1")
assert b"available" in resp2.data.lower()
def test_delete_device_removed(seeded_client):
seeded_client.post("/device/1/delete")
resp = seeded_client.get("/device/1")
assert resp.status_code == 404
# ------------------------------------------------------------------ #
# Full round-trip
# ------------------------------------------------------------------ #
def test_add_assign_delete_battery(client):
# Add a device
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
# Add a battery
client.post("/battery/add", data={"label": "RT-01", "brand": "AcmeBrand", "status": "available"})
# Assign it
resp = client.post("/battery/1/assign", data={"device_id": "1"}, follow_redirects=True)
assert b"Gadget" in resp.data
# Delete it
client.post("/battery/1/delete")
assert client.get("/battery/1").status_code == 404