Compare commits

...
2 Commits
Author SHA1 Message Date
iterminate bd171d2384 Add Gitea CI workflow; fix ruff lint issues
CI / test (push) Successful in 1m16s
2026-06-28 07:44:04 -05:00
iterminate 129b1eff04 Nav active highlighting; reorder add battery form; export/import icons 2026-06-21 13:20:17 -05:00
14 changed files with 86 additions and 55 deletions
+30
View File
@@ -0,0 +1,30 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.13"
- name: Install dependencies
run: |
pip install ruff
pip install -r requirements.txt
- name: Lint
run: ruff check .
- name: Run tests
run: pytest tests/ -v
+20 -20
View File
@@ -35,7 +35,7 @@ def _safe_next(default_url):
def _filter_compatible(query, battery_size):
"""Restrict a Battery query to size-compatible batteries (matching size or unsized)."""
if battery_size:
query = query.filter((Battery.size == battery_size) | (Battery.size == None))
query = query.filter((Battery.size == battery_size) | (Battery.size == None)) # noqa: E711
return query
@@ -130,7 +130,7 @@ def create_app(config_object="config"):
avail_count = sum(1 for b in batteries if b.status == "available")
installed_count = sum(1 for b in batteries if b.status == "installed")
retired_count = sum(1 for b in batteries if b.status == "retired")
devices = db.query(Device).filter(Device.parent_id == None).all()
devices = db.query(Device).filter(Device.parent_id == None).all() # noqa: E711
total_devices = len(devices)
full_devices = sum(
1 for d in devices
@@ -283,16 +283,16 @@ def create_app(config_object="config"):
.order_by(BatteryPctLog.recorded_at.desc())
.all())
charge_logs_data = [
{"id": l.id, "date": l.charged_date, "cycles": l.increment_cycles, "notes": l.notes or ""}
for l in charge_logs
{"id": log.id, "date": log.charged_date, "cycles": log.increment_cycles, "notes": log.notes or ""}
for log in charge_logs
]
capacity_tests_data = [
{"id": t.id, "date": t.tested_date, "mah": t.tested_capacity_mah, "notes": t.notes or ""}
for t in sorted(capacity_tests, key=lambda t: (t.tested_date, t.id), reverse=True)
]
pct_logs_data = [
{"recorded_at": str(l.recorded_at), "pct": l.percentage, "source": l.source or ""}
for l in pct_logs
{"recorded_at": str(log.recorded_at), "pct": log.percentage, "source": log.source or ""}
for log in pct_logs
]
return render_template("battery_detail.html", battery=battery,
storage_locations=storage_locations,
@@ -699,7 +699,7 @@ def create_app(config_object="config"):
@app.route("/device/")
def device_list():
devices = db.query(Device).filter(Device.parent_id == None).order_by(Device.name).all()
devices = db.query(Device).filter(Device.parent_id == None).order_by(Device.name).all() # noqa: E711
device_types = sorted({d.device_type for d in devices if d.device_type})
device_locations = sorted({d.location for d in devices if d.location})
device_battery_sizes = sorted({d.battery_size for d in devices if d.battery_size})
@@ -1237,9 +1237,9 @@ def create_app(config_object="config"):
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "battery_id", "battery_label", "charged_date", "increment_cycles", "notes"])
for l in rows:
w.writerow([l.id, l.battery_id, l.battery.label,
l.charged_date, l.increment_cycles, l.notes or ""])
for row in rows:
w.writerow([row.id, row.battery_id, row.battery.label,
row.charged_date, row.increment_cycles, row.notes or ""])
return buf.getvalue()
def _capacity_tests_csv():
@@ -1257,9 +1257,9 @@ def create_app(config_object="config"):
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["id", "battery_id", "battery_label", "percentage", "recorded_at", "source"])
for l in rows:
w.writerow([l.id, l.battery_id, l.battery.label,
l.percentage, l.recorded_at, l.source or ""])
for row in rows:
w.writerow([row.id, row.battery_id, row.battery.label,
row.percentage, row.recorded_at, row.source or ""])
return buf.getvalue()
@app.route("/export")
@@ -1334,10 +1334,10 @@ def create_app(config_object="config"):
for d in devices
],
"charge_logs": [
{"id": l.id, "battery_id": l.battery_id, "battery_label": l.battery.label,
"charged_date": l.charged_date, "increment_cycles": l.increment_cycles,
"notes": l.notes}
for l in charge_logs
{"id": log.id, "battery_id": log.battery_id, "battery_label": log.battery.label,
"charged_date": log.charged_date, "increment_cycles": log.increment_cycles,
"notes": log.notes}
for log in charge_logs
],
"capacity_tests": [
{"id": t.id, "battery_id": t.battery_id, "battery_label": t.battery.label,
@@ -1346,9 +1346,9 @@ def create_app(config_object="config"):
for t in capacity_tests
],
"pct_logs": [
{"id": l.id, "battery_id": l.battery_id, "battery_label": l.battery.label,
"percentage": l.percentage, "recorded_at": l.recorded_at, "source": l.source}
for l in pct_logs
{"id": log.id, "battery_id": log.battery_id, "battery_label": log.battery.label,
"percentage": log.percentage, "recorded_at": log.recorded_at, "source": log.source}
for log in pct_logs
],
}
return Response(json.dumps(payload, indent=2), mimetype="application/json",
+1 -1
View File
@@ -37,7 +37,7 @@ class HaPoller:
self._poll_once()
def _poll_once(self):
from models import Battery, BatteryPctLog, Device # local import avoids circular-import risk
from models import BatteryPctLog, Device # local import avoids circular-import risk
session = self._Session()
try:
-1
View File
@@ -1,4 +1,3 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table, UniqueConstraint
from sqlalchemy.orm import declarative_base, relationship
+3 -1
View File
@@ -1,5 +1,7 @@
"""Generate solid-color PNG icons for PWA manifest using stdlib only (no Pillow)."""
import zlib, struct, os
import zlib
import struct
import os
def make_png(size, rgb=(0x25, 0x63, 0xEB)):
"""Create a minimal valid RGB PNG of the given size filled with one color."""
-1
View File
@@ -16,7 +16,6 @@ Usage:
import os
import shutil
import sys
from datetime import date
from pathlib import Path
+5 -5
View File
@@ -31,11 +31,11 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine, text # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
import config
from models import Base, Battery, BatteryPctLog, CapacityTest, ChargeLog, Device
import config # noqa: E402
from models import Base, Battery, BatteryPctLog, CapacityTest, ChargeLog, Device # noqa: E402
# ---------------------------------------------------------------------------
@@ -62,7 +62,7 @@ def collect_credentials() -> str:
"""Return a MariaDB SQLAlchemy URL, prompting for any missing pieces."""
url = os.environ.get("MARIADB_URL", "").strip()
if url:
print(f" Using MARIADB_URL from environment.")
print(" Using MARIADB_URL from environment.")
return url
print("Enter MariaDB connection details (press Enter to accept defaults):\n")
+2
View File
@@ -116,6 +116,7 @@
nav .brand:hover { text-decoration: none; opacity: 0.9; }
nav a { color: #bfdbfe; font-size: 0.9rem; padding: 0.25rem 0.5rem; border-radius: 4px; white-space: nowrap; }
nav a:hover { background: #1d4ed8; color: #fff; text-decoration: none; }
nav a.active { background: #1d4ed8; color: #fff; }
/* Layout */
.container { max-width: 960px; margin: 1.5rem auto; padding: 0 1rem; }
@@ -196,6 +197,7 @@
nav { background: #0c1a3b; }
nav a { color: #93c5fd; }
nav a:hover { background: #1e3a8a; color: #fff; }
nav a.active { background: #1e3a8a; color: #fff; }
nav .brand { color: #e2e8f0; }
#bulk-toolbar { background: var(--bg-toolbar) !important; }
+5 -4
View File
@@ -17,9 +17,10 @@
</head>
<body>
<nav>
{% set ep = request.endpoint or '' %}
<a class="brand" href="{{ url_for('home') }}">Battery Tracker</a>
<a href="{{ url_for('battery_list') }}">Batteries</a>
<a href="{{ url_for('device_list') }}">Devices</a>
<a href="{{ url_for('battery_list') }}"{% if ep.startswith('battery') %} class="active"{% endif %}>Batteries</a>
<a href="{{ url_for('device_list') }}"{% if ep.startswith('device') %} class="active"{% endif %}>Devices</a>
</nav>
<div class="container">
@@ -52,13 +53,13 @@
</svg>
<span>Home</span>
</a>
<a href="{{ url_for('battery_list') }}" class="bottom-nav-item{% if ep == 'battery_list' %} active{% endif %}">
<a href="{{ url_for('battery_list') }}" class="bottom-nav-item{% if ep.startswith('battery') %} active{% endif %}">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="7" width="16" height="10" rx="2"/><path d="M22 11v2"/>
</svg>
<span>Batteries</span>
</a>
<a href="{{ url_for('device_list') }}" class="bottom-nav-item{% if ep.startswith('device') and ep != 'device_add' %} active{% endif %}">
<a href="{{ url_for('device_list') }}" class="bottom-nav-item{% if ep.startswith('device') %} active{% endif %}">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
+13 -13
View File
@@ -23,6 +23,19 @@
style="display:none;margin-top:0.4rem;">
</div>
<div class="form-group">
<label>Size</label>
<select id="size-select" onchange="metaSelectChanged(this,'size')">
<option value="">— none —</option>
{% for opt in ['AA','AAA','C','D','9V','18650','21700','14500','26650','CR2032','CR123A'] %}
<option value="{{ opt }}">{{ opt }}</option>
{% endfor %}
<option value="__new__">Other…</option>
</select>
<input type="text" id="size" name="size" value=""
placeholder="Enter size" style="display:none;margin-top:0.4rem;">
</div>
<div class="form-group">
<label for="count">Quantity</label>
<input type="number" id="count" name="count" value="{{ form_count|default(1) }}" min="1" max="50">
@@ -42,19 +55,6 @@
value="" placeholder="e.g. 2000 — optional">
</div>
<div class="form-group">
<label>Size</label>
<select id="size-select" onchange="metaSelectChanged(this,'size')">
<option value="">— none —</option>
{% for opt in ['AA','AAA','C','D','9V','18650','21700','14500','26650','CR2032','CR123A'] %}
<option value="{{ opt }}">{{ opt }}</option>
{% endfor %}
<option value="__new__">Other…</option>
</select>
<input type="text" id="size" name="size" value=""
placeholder="Enter size" style="display:none;margin-top:0.4rem;">
</div>
<div class="form-group">
<label>Chemistry</label>
<select id="chemistry-select" onchange="metaSelectChanged(this,'chemistry')">
+2 -2
View File
@@ -117,8 +117,8 @@
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('battery_list') }}">View All Batteries</a>
<a class="btn btn-primary" style="text-align:center;" href="{{ url_for('device_add') }}">+ Add Device</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('device_list') }}">View All Devices</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('export_page') }}">Export Data</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('import_page') }}">Import Data</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('export_page') }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Export Data</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('import_page') }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Import Data</a>
</div>
{% endblock %}
+3 -5
View File
@@ -8,7 +8,8 @@ The `seeded_client` fixture pre-populates:
BrandX 002 (id=3, retired)
"""
import pytest
import io
import json as _json
# ------------------------------------------------------------------ #
@@ -169,7 +170,7 @@ def test_edit_details_percentage_valid(seeded_client):
data = _json.loads(seeded_client.get("/export/all.json").data)
bat = next(b for b in data["batteries"] if b["id"] == 1)
assert bat["battery_percentage"] == 100
assert any(l["battery_id"] == 1 and l["source"] == "manual" for l in data["pct_logs"])
assert any(entry["battery_id"] == 1 and entry["source"] == "manual" for entry in data["pct_logs"])
def test_assign_battery(seeded_client):
@@ -699,9 +700,6 @@ def test_bulk_install_filters_by_size(client):
# Import
# ------------------------------------------------------------------ #
import io
import json as _json
def _make_import_payload(devices=None, batteries=None,
charge_logs=None, capacity_tests=None, pct_logs=None):
-1
View File
@@ -6,7 +6,6 @@ ha_client/ha_poller branches, export/import edge cases, bulk actions.
import io
import json
import pytest
from unittest.mock import patch, MagicMock
+2 -1
View File
@@ -484,7 +484,8 @@ def test_poll_skips_update_when_percentage_unchanged(ha_app, ha_client_f):
engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
s = sessionmaker(bind=engine)()
s.get(Battery, 1).battery_percentage = 50
s.commit(); s.close()
s.commit()
s.close()
from ha_client import HomeAssistantClient
from ha_poller import HaPoller