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
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Assign {{ battery.label }} — Battery Tracker{% endblock %}
{% block content %}
<h1>Assign {{ battery.label }}</h1>
<p class="text-muted" style="margin-bottom:1rem;">Brand: {{ battery.brand }}</p>
<div class="card">
{% if devices %}
<form method="post" action="{{ url_for('battery_assign', battery_id=battery.id) }}">
<div class="form-group">
<label>Select Device</label>
{% for device in devices %}
{% set full = device.installed_count() >= device.battery_slots %}
{% set mix = device.installed_brands() and battery.brand not in device.installed_brands() %}
<div style="margin-bottom:0.75rem;padding:0.6rem 0.75rem;border:1px solid #e2e8f0;border-radius:4px;
{% if full %}opacity:0.5;{% endif %}background:#fff;">
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
<input type="radio" name="device_id" value="{{ device.id }}"
{% if full %}disabled{% endif %}
style="cursor:{% if full %}not-allowed{% else %}pointer{% endif %};">
<span>
<strong>{{ device.name }}</strong>
<span class="text-muted">({{ device.installed_count() }}/{{ device.battery_slots }} slots used)</span>
{% if full %}
<span class="badge badge-retired">Full</span>
{% endif %}
</span>
</label>
{% if mix and not full %}
<p class="text-warning" style="margin-top:0.3rem;margin-left:1.6rem;">
⚠ Already has {{ device.installed_brands()|join(', ') }} — mixing brands not recommended.
</p>
{% endif %}
</div>
{% endfor %}
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Assign Battery</button>
<a class="btn btn-secondary" href="{{ url_for('battery_detail', battery_id=battery.id) }}">Cancel</a>
</div>
</form>
{% else %}
<p class="text-muted">No devices exist yet. <a href="{{ url_for('device_add') }}">Add a device first.</a></p>
{% endif %}
</div>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Battery Tracker{% endblock %}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 15px; background: #f5f5f5; color: #222; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
/* Nav */
nav {
background: #1e40af;
color: #fff;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding: 0.75rem 1rem;
}
nav .brand { font-weight: 700; font-size: 1.1rem; margin-right: auto; color: #fff; }
nav a { color: #bfdbfe; font-size: 0.9rem; padding: 0.25rem 0.5rem; border-radius: 4px; }
nav a:hover { background: #1d4ed8; color: #fff; text-decoration: none; }
/* Layout */
.container { max-width: 960px; margin: 1.5rem auto; padding: 0 1rem; }
/* Flash messages */
.flash { padding: 0.6rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem; }
.flash.success { background: #dcfce7; color: #166534; border: 1px solid #86efac; }
.flash.error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.flash.warning { background: #fef9c3; color: #854d0e; border: 1px solid #fde047; }
/* Cards / boxes */
.card { background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,.1); padding: 1.25rem; margin-bottom: 1rem; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 0.5rem 0.75rem; background: #f1f5f9; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; color: #64748b; border-bottom: 2px solid #e2e8f0; }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #e2e8f0; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8fafc; }
/* Status badges */
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
.badge-available { background: #dcfce7; color: #166534; }
.badge-installed { background: #dbeafe; color: #1e40af; }
.badge-retired { background: #f1f5f9; color: #64748b; }
.badge-warning { background: #fef9c3; color: #854d0e; }
/* Buttons */
.btn { display: inline-block; padding: 0.4rem 0.9rem; border-radius: 4px; border: none; cursor: pointer; font-size: 0.875rem; font-family: inherit; text-decoration: none; }
.btn:hover { text-decoration: none; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-danger:hover { background: #b91c1c; }
.btn-warning { background: #d97706; color: #fff; }
.btn-warning:hover { background: #b45309; }
.btn-secondary { background: #e2e8f0; color: #334155; }
.btn-secondary:hover { background: #cbd5e1; }
.btn-sm { padding: 0.25rem 0.6rem; font-size: 0.8rem; }
/* Forms */
.form-group { margin-bottom: 1rem; }
label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 0.3rem; color: #374151; }
input[type=text], input[type=number], select, textarea {
width: 100%; padding: 0.45rem 0.65rem; border: 1px solid #d1d5db;
border-radius: 4px; font-size: 0.9rem; font-family: inherit;
}
input:focus, select:focus, textarea:focus { outline: 2px solid #3b82f6; border-color: #3b82f6; }
textarea { min-height: 80px; resize: vertical; }
.form-actions { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; margin-top: 1.25rem; }
/* Headings */
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #1e293b; }
h2 { font-size: 1.15rem; margin-bottom: 0.75rem; color: #334155; }
/* Inline form (for POST buttons in tables) */
form.inline { display: inline; }
/* Warning text */
.text-warning { color: #b45309; font-size: 0.8rem; }
.text-danger { color: #dc2626; }
.text-muted { color: #6b7280; font-size: 0.85rem; }
</style>
</head>
<body>
<nav>
<span class="brand">Battery Tracker</span>
<a href="{{ url_for('dashboard') }}">Dashboard</a>
<a href="{{ url_for('device_list') }}">Devices</a>
<a href="{{ url_for('battery_add') }}">+ Battery</a>
<a href="{{ url_for('device_add') }}">+ Device</a>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Add Battery — Battery Tracker{% endblock %}
{% block content %}
<h1>Add Battery</h1>
<div class="card">
<form method="post" action="{{ url_for('battery_add') }}">
<div class="form-group">
<label for="label">Label <span class="text-danger">*</span></label>
<input type="text" id="label" name="label" value="{{ form_label|default('') }}"
placeholder="e.g. ENL-17" required>
</div>
<div class="form-group">
<label for="brand">Brand <span class="text-danger">*</span></label>
<input type="text" id="brand" name="brand" value="{{ form_brand|default('') }}"
placeholder="e.g. Panasonic Eneloop" required>
</div>
<div class="form-group">
<label for="status">Initial Status</label>
<select id="status" name="status">
<option value="available" {% if form_status|default('available') == 'available' %}selected{% endif %}>Available</option>
<option value="installed" {% if form_status|default('') == 'installed' %}selected{% endif %}>Installed</option>
<option value="retired" {% if form_status|default('') == 'retired' %}selected{% endif %}>Retired</option>
</select>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" placeholder="Optional notes…">{{ form_notes|default('') }}</textarea>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Add Battery</button>
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}">Cancel</a>
</div>
</form>
</div>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Delete {{ battery.label }} — Battery Tracker{% endblock %}
{% block content %}
<h1>Delete Battery</h1>
<div class="card">
<p style="margin-bottom:1rem;">
You are about to <strong>permanently delete</strong> battery
<strong>{{ battery.label }}</strong> ({{ battery.brand }}).
This action cannot be undone.
</p>
{% if battery.is_installed() %}
<p class="text-warning" style="margin-bottom:1rem;">
⚠ This battery is currently installed in
<strong>{{ battery.device.name }}</strong>.
Deleting it will free that slot.
</p>
{% endif %}
<div class="form-actions">
<form method="post" action="{{ url_for('battery_delete', battery_id=battery.id) }}">
<button class="btn btn-danger" type="submit">Confirm Delete</button>
</form>
<a class="btn btn-secondary" href="{{ url_for('battery_detail', battery_id=battery.id) }}">Cancel</a>
</div>
</div>
{% endblock %}
+70
View File
@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block title %}{{ battery.label }} — Battery Tracker{% endblock %}
{% block content %}
<h1>{{ battery.label }}</h1>
<div class="card">
<table style="width:auto;border:none;">
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Label</td>
<td style="border:none;">{{ battery.label }}</td>
</tr>
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Brand</td>
<td style="border:none;">{{ battery.brand }}</td>
</tr>
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Status</td>
<td style="border:none;"><span class="badge badge-{{ battery.status }}">{{ battery.status|capitalize }}</span></td>
</tr>
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Device</td>
<td style="border:none;">
{% if battery.device %}
<a href="{{ url_for('device_detail', device_id=battery.device.id) }}">{{ battery.device.name }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
</table>
</div>
<!-- Notes -->
<div class="card">
<h2>Notes</h2>
<form method="post" action="{{ url_for('battery_edit_notes', battery_id=battery.id) }}">
<div class="form-group">
<textarea name="notes" placeholder="No notes yet…">{{ battery.notes or '' }}</textarea>
</div>
<button class="btn btn-primary" type="submit">Save Notes</button>
</form>
</div>
<!-- Actions -->
<div class="card">
<h2>Actions</h2>
<div class="form-actions">
{% if battery.is_available() %}
<a class="btn btn-primary" href="{{ url_for('battery_assign', battery_id=battery.id) }}">Assign to Device</a>
{% endif %}
{% if battery.is_installed() %}
<form method="post" action="{{ url_for('battery_unassign', battery_id=battery.id) }}">
<button class="btn btn-warning" type="submit">Unassign</button>
</form>
{% endif %}
{% if not battery.is_retired() %}
<form method="post" action="{{ url_for('battery_retire', battery_id=battery.id) }}">
<button class="btn btn-secondary" type="submit">Retire Battery</button>
</form>
{% endif %}
<a class="btn btn-danger" href="{{ url_for('battery_delete', battery_id=battery.id) }}">Delete Battery</a>
</div>
</div>
<a class="text-muted" href="{{ url_for('dashboard') }}">&larr; Back to Dashboard</a>
{% endblock %}
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}Dashboard — Battery Tracker{% endblock %}
{% block content %}
<h1>Battery Dashboard</h1>
{% set total = batteries|length %}
{% set available = batteries|selectattr('status','eq','available')|list|length %}
{% set installed = batteries|selectattr('status','eq','installed')|list|length %}
{% set retired = batteries|selectattr('status','eq','retired')|list|length %}
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
<div class="card" style="flex:1;min-width:120px;text-align:center;">
<div style="font-size:1.8rem;font-weight:700;">{{ total }}</div>
<div class="text-muted">Total</div>
</div>
<div class="card" style="flex:1;min-width:120px;text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:#166534;">{{ available }}</div>
<div class="text-muted">Available</div>
</div>
<div class="card" style="flex:1;min-width:120px;text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:#1e40af;">{{ installed }}</div>
<div class="text-muted">Installed</div>
</div>
<div class="card" style="flex:1;min-width:120px;text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:#64748b;">{{ retired }}</div>
<div class="text-muted">Retired</div>
</div>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Label</th>
<th>Brand</th>
<th>Status</th>
<th>Assigned To</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for b in batteries %}
<tr>
<td><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
<td>{{ b.brand }}</td>
<td>
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
</td>
<td>
{% if b.device %}
<a href="{{ url_for('device_detail', device_id=b.device.id) }}">{{ b.device.name }}</a>
{% if b.device.has_mixed_brands() %}
<span class="badge badge-warning" title="Mixed brands in this device">⚠ mixed</span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td style="white-space:nowrap;">
<a class="btn btn-sm btn-secondary" href="{{ url_for('battery_detail', battery_id=b.id) }}">View</a>
{% if b.is_available() %}
<a class="btn btn-sm btn-primary" href="{{ url_for('battery_assign', battery_id=b.id) }}">Assign</a>
{% endif %}
{% if b.is_installed() %}
<form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}">
<button class="btn btn-sm btn-warning" type="submit">Unassign</button>
</form>
{% endif %}
{% if not b.is_retired() %}
<form class="inline" method="post" action="{{ url_for('battery_retire', battery_id=b.id) }}">
<button class="btn btn-sm btn-secondary" type="submit">Retire</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-muted" style="text-align:center;padding:1rem;">No batteries found. <a href="{{ url_for('battery_add') }}">Add one.</a></td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Add Device — Battery Tracker{% endblock %}
{% block content %}
<h1>Add Device</h1>
<div class="card">
<form method="post" action="{{ url_for('device_add') }}">
<div class="form-group">
<label for="name">Device Name <span class="text-danger">*</span></label>
<input type="text" id="name" name="name" value="{{ form_name|default('') }}"
placeholder="e.g. Game Controller" required>
</div>
<div class="form-group">
<label for="battery_slots">Battery Slots <span class="text-danger">*</span></label>
<input type="number" id="battery_slots" name="battery_slots"
value="{{ form_slots|default(1) }}" min="1" required>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" placeholder="Optional notes…">{{ form_notes|default('') }}</textarea>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Add Device</button>
<a class="btn btn-secondary" href="{{ url_for('device_list') }}">Cancel</a>
</div>
</form>
</div>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}{{ device.name }} — Battery Tracker{% endblock %}
{% block content %}
<h1>{{ device.name }}</h1>
<div class="card">
<table style="width:auto;border:none;">
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Slots</td>
<td style="border:none;">{{ device.installed_count() }} / {{ device.battery_slots }} used</td>
</tr>
{% if device.has_mixed_brands() %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Warning</td>
<td style="border:none;"><span class="badge badge-warning">⚠ Mixed brands installed</span></td>
</tr>
{% endif %}
{% if device.notes %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
<td style="border:none;">{{ device.notes }}</td>
</tr>
{% endif %}
</table>
</div>
<div class="card">
<h2>Installed Batteries</h2>
{% set installed = device.batteries | selectattr('status', 'eq', 'installed') | list %}
{% if installed %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Label</th><th>Brand</th><th>Notes</th><th>Actions</th></tr>
</thead>
<tbody>
{% for b in installed %}
<tr>
<td><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
<td>{{ b.brand }}</td>
<td class="text-muted">{{ b.notes or '—' }}</td>
<td>
<form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}">
<button class="btn btn-sm btn-warning" type="submit">Unassign</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No batteries installed.</p>
{% endif %}
</div>
<div class="card">
<h2>Delete Device</h2>
<p style="margin-bottom:1rem;" class="text-muted">
Deleting this device will unassign all installed batteries and mark them available.
</p>
<form method="post" action="{{ url_for('device_delete', device_id=device.id) }}">
<button class="btn btn-danger" type="submit">Delete {{ device.name }}</button>
</form>
</div>
<a class="text-muted" href="{{ url_for('device_list') }}">&larr; Back to Devices</a>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Devices — Battery Tracker{% endblock %}
{% block content %}
<h1>Devices</h1>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Slots</th>
<th>Installed</th>
<th>Brands</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<td><a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a></td>
<td>{{ d.battery_slots }}</td>
<td>
{{ d.installed_count() }} / {{ d.battery_slots }}
{% if d.installed_count() >= d.battery_slots %}
<span class="badge badge-retired">Full</span>
{% endif %}
</td>
<td>
{% set brands = d.installed_brands() %}
{% if brands %}
{{ brands|join(', ') }}
{% if d.has_mixed_brands() %}
<span class="badge badge-warning">⚠ mixed</span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td style="white-space:nowrap;">
<a class="btn btn-sm btn-secondary" href="{{ url_for('device_detail', device_id=d.id) }}">View</a>
<form class="inline" method="post" action="{{ url_for('device_delete', device_id=d.id) }}"
onsubmit="return confirm('Delete {{ d.name }}? All installed batteries will be unassigned.');">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-muted" style="text-align:center;padding:1rem;">No devices yet. <a href="{{ url_for('device_add') }}">Add one.</a></td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<a class="btn btn-primary" href="{{ url_for('device_add') }}">+ Add Device</a>
{% endblock %}