Compare commits
21
Commits
2a54cd8297
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd171d2384 | ||
|
|
129b1eff04 | ||
|
|
18456cf8a4 | ||
|
|
0b893ffcbb | ||
|
|
1980453cab | ||
|
|
c0115aebf9 | ||
|
|
ff1a883170 | ||
|
|
0223b820be | ||
|
|
1309c2f5db | ||
|
|
faeb8e8c04 | ||
|
|
b192424978 | ||
|
|
6f0dfb0b7f | ||
|
|
7ed7bb2bb0 | ||
|
|
17bad3f36b | ||
|
|
a9835fee1e | ||
|
|
2a103f52a5 | ||
|
|
d41fe7f4ab | ||
|
|
d1fc80164f | ||
|
|
ab1340998e | ||
|
|
27ed4c9420 | ||
|
|
23eeeafff7 |
@@ -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
|
||||
@@ -41,8 +41,9 @@ journalctl --user -u battery-tracker -f
|
||||
|
||||
### Models
|
||||
- `Battery`: label (unique), brand, status (`available`/`installed`/`retired`), device_id (FK nullable, `ondelete=SET NULL`), notes, size, chemistry, capacity_mah, tested_capacity_mah, tested_date, charge_cycles, purchase_date, storage_location, battery_percentage
|
||||
- `Device`: name (unique), battery_slots, device_type, notes, ha_entity_id
|
||||
- Helper methods: `Battery.is_available/installed/retired()`, `Device.installed_count()`, `Device.installed_brands()`, `Device.has_mixed_brands()`
|
||||
- `Device`: name, battery_slots, device_type, battery_size, location, notes, ha_entity_id, parent_id (self-FK, `ondelete=SET NULL`), parent_key (`-1` for top-level, else parent_id; uniqueness is `UNIQUE(parent_key, name)` so names are scoped per parent). Sub-components are one level deep only; parents have battery_slots=0, battery_size/device_type/location NULL.
|
||||
- History/log models: `ChargeLog`, `CapacityTest`, `BatteryPctLog` (per-battery, FK), `Logbook` (attached to a battery or device via association tables, cascade delete-orphan)
|
||||
- Helper methods: `Battery.is_available/installed/retired()`, `Device.installed_count()`, `Device.installed_brands()`, `Device.has_mixed_brands()`, `Device.has_children()`, `Device.is_subcomponent()`, `Device.effective_installed_count()/effective_slots()` (aggregate over children), `Device.installed_batteries()` (installed batteries incl. sub-components)
|
||||
|
||||
### Business rules (enforced in routes, not DB constraints)
|
||||
- Assigning a retired battery → hard block with flash error
|
||||
@@ -59,7 +60,7 @@ journalctl --user -u battery-tracker -f
|
||||
The dashboard route builds an `active` list (`status in ("available", "installed")`) used for all warning logic. Client-side filtering uses `data-status` attributes on each table row and `applyFilters()` in JS. The default filter state is `"active"` (retired rows hidden on page load); the Reset button restores `"active"`, not an empty filter. Column visibility choices are stored in `localStorage`.
|
||||
|
||||
### Home Assistant integration (optional)
|
||||
`ha_client.py` wraps the HA REST API (`GET /api/states/<entity_id>`). `ha_poller.py` runs a daemon thread started in `create_app` only when `HOMEASSISTANT_URL` and `HOMEASSISTANT_API_KEY` are set. The poller queries all `Device` rows with `ha_entity_id IS NOT NULL`, fetches the current percentage from HA, and writes it to `battery_percentage` on each installed battery in that device. The poller uses its own `sessionmaker` session (not the request-scoped `scoped_session`). When HA is not configured the app behaves exactly as before — all HA UI is gated on `ha_enabled` passed to templates.
|
||||
`ha_client.py` wraps the HA REST API (`GET /api/states/<entity_id>`). `ha_poller.py` runs a daemon thread started in `create_app` only when `HOMEASSISTANT_URL` and `HOMEASSISTANT_API_KEY` are set. The poller queries all `Device` rows with `ha_entity_id IS NOT NULL`, fetches the current percentage from HA (clamped to 0–100), and writes it to `battery_percentage` on each installed battery in that device — including batteries installed in its sub-components (`Device.installed_batteries()`). The poller uses its own `sessionmaker` session (not the request-scoped `scoped_session`). When HA is not configured the app behaves exactly as before — all HA UI is gated on `ha_enabled` passed to templates.
|
||||
|
||||
### Adding new columns to existing DB
|
||||
`create_all()` won't add columns to existing tables. Run via Python:
|
||||
|
||||
@@ -81,7 +81,7 @@ Battery Tracker gives you a single source of truth:
|
||||
| Database | SQLite (dev) / MariaDB (prod) |
|
||||
| WSGI server | Waitress |
|
||||
| Process manager | systemd user service |
|
||||
| Tests | pytest (82 tests) |
|
||||
| Tests | pytest (255 tests, 99% coverage) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,6 +23,22 @@ def _parse_date(val: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_next(default_url):
|
||||
"""Return the form's `next` URL only if it is a local path (rejects
|
||||
protocol-relative `//host` and `/\\host` redirects)."""
|
||||
nxt = request.form.get("next", "")
|
||||
if nxt.startswith("/") and not nxt.startswith("//") and not nxt.startswith("/\\"):
|
||||
return nxt
|
||||
return 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)) # noqa: E711
|
||||
return query
|
||||
|
||||
|
||||
def _record_charge(db, battery, date_val, increment, notes):
|
||||
"""Apply one charge event to battery. Caller must call db.commit()."""
|
||||
if increment:
|
||||
@@ -81,7 +97,7 @@ def create_app(config_object="config"):
|
||||
poller.start()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Dashboard
|
||||
# Home / Battery list
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@app.route("/sw.js")
|
||||
@@ -90,26 +106,14 @@ def create_app(config_object="config"):
|
||||
mimetype="application/javascript")
|
||||
|
||||
@app.route("/")
|
||||
def dashboard():
|
||||
batteries = db.query(Battery).order_by(Battery.label).all()
|
||||
storage_locations = [
|
||||
r[0] for r in db.query(Battery.storage_location)
|
||||
.filter(Battery.storage_location.isnot(None))
|
||||
.distinct().order_by(Battery.storage_location).all()
|
||||
]
|
||||
devices = db.query(Device).order_by(Device.name).all()
|
||||
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots and not d.has_children()]
|
||||
def home():
|
||||
batteries = db.query(Battery).all()
|
||||
today = date.today()
|
||||
one_year_ago = (today - timedelta(days=365)).isoformat()
|
||||
total_charges = db.query(func.count(ChargeLog.id)).scalar() or 0
|
||||
charges_last_year = (db.query(func.count(ChargeLog.id))
|
||||
.filter(ChargeLog.charged_date >= one_year_ago)
|
||||
.scalar()) or 0
|
||||
last_charged_map = {
|
||||
r[0]: r[1]
|
||||
for r in db.query(ChargeLog.battery_id, func.max(ChargeLog.charged_date))
|
||||
.group_by(ChargeLog.battery_id).all()
|
||||
}
|
||||
active = [b for b in batteries if b.status in ("available", "installed")]
|
||||
needs_attention = {
|
||||
"low_capacity": [
|
||||
@@ -122,14 +126,57 @@ def create_app(config_object="config"):
|
||||
if b.battery_percentage is not None and b.battery_percentage < 20
|
||||
] if ha_client.enabled else [],
|
||||
}
|
||||
return render_template("dashboard.html", batteries=batteries,
|
||||
total_batteries = len(batteries)
|
||||
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() # noqa: E711
|
||||
total_devices = len(devices)
|
||||
full_devices = sum(
|
||||
1 for d in devices
|
||||
if d.effective_slots() > 0 and d.effective_installed_count() >= d.effective_slots()
|
||||
)
|
||||
partial_devices = sum(
|
||||
1 for d in devices
|
||||
if 0 < d.effective_installed_count() < d.effective_slots()
|
||||
)
|
||||
empty_devices = sum(1 for d in devices if d.effective_installed_count() == 0)
|
||||
return render_template("home.html",
|
||||
total_batteries=total_batteries,
|
||||
avail_count=avail_count,
|
||||
installed_count=installed_count,
|
||||
retired_count=retired_count,
|
||||
total_devices=total_devices,
|
||||
full_devices=full_devices,
|
||||
partial_devices=partial_devices,
|
||||
empty_devices=empty_devices,
|
||||
total_charges=total_charges,
|
||||
charges_last_year=charges_last_year,
|
||||
needs_attention=needs_attention,
|
||||
ha_enabled=ha_client.enabled,
|
||||
today=today)
|
||||
|
||||
@app.route("/battery/")
|
||||
def battery_list():
|
||||
batteries = db.query(Battery).order_by(Battery.label).all()
|
||||
storage_locations = [
|
||||
r[0] for r in db.query(Battery.storage_location)
|
||||
.filter(Battery.storage_location.isnot(None))
|
||||
.distinct().order_by(Battery.storage_location).all()
|
||||
]
|
||||
devices = db.query(Device).order_by(Device.name).all()
|
||||
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots and not d.has_children()]
|
||||
today = date.today()
|
||||
last_charged_map = {
|
||||
r[0]: r[1]
|
||||
for r in db.query(ChargeLog.battery_id, func.max(ChargeLog.charged_date))
|
||||
.group_by(ChargeLog.battery_id).all()
|
||||
}
|
||||
return render_template("battery_list.html", batteries=batteries,
|
||||
storage_locations=storage_locations, devices=devices,
|
||||
devices_with_slots=devices_with_slots,
|
||||
ha_enabled=ha_client.enabled,
|
||||
total_charges=total_charges,
|
||||
charges_last_year=charges_last_year,
|
||||
last_charged_map=last_charged_map,
|
||||
needs_attention=needs_attention,
|
||||
today=today)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -185,7 +232,7 @@ def create_app(config_object="config"):
|
||||
purchase_date=purchase_date, storage_location=storage_location))
|
||||
db.commit()
|
||||
flash(f"Added {count} {brand} batter{'y' if count == 1 else 'ies'}.", "success")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
|
||||
brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()]
|
||||
storage_locations = [
|
||||
@@ -236,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,
|
||||
@@ -284,6 +331,9 @@ def create_app(config_object="config"):
|
||||
battery.purchase_date = _parse_date(purchase_raw) if purchase_raw else None
|
||||
battery.storage_location = f.get("storage_location", "").strip() or None
|
||||
new_pct = _int("battery_percentage")
|
||||
if new_pct is not None and not (0 <= new_pct <= 100):
|
||||
flash("Battery percentage must be between 0 and 100.", "error")
|
||||
return redirect(url_for("battery_detail", battery_id=battery_id))
|
||||
if new_pct != battery.battery_percentage:
|
||||
battery.battery_percentage = new_pct
|
||||
if new_pct is not None:
|
||||
@@ -435,7 +485,7 @@ def create_app(config_object="config"):
|
||||
battery.device_id = device.id
|
||||
db.commit()
|
||||
flash(f"{battery.label} assigned to {device.name}.", "success")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
|
||||
return render_template("assign.html", battery=battery, devices=devices_with_slots)
|
||||
|
||||
@@ -452,8 +502,7 @@ def create_app(config_object="config"):
|
||||
battery.device_id = None
|
||||
db.commit()
|
||||
flash(f"{battery.label} unassigned and marked available.", "success")
|
||||
next_url = request.form.get("next", "")
|
||||
return redirect(next_url if next_url.startswith("/") else url_for("dashboard"))
|
||||
return redirect(_safe_next(url_for("battery_list")))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — retire
|
||||
@@ -471,7 +520,7 @@ def create_app(config_object="config"):
|
||||
battery.device_id = None
|
||||
db.commit()
|
||||
flash(f"{battery.label} has been retired.", "success")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — unretire
|
||||
@@ -504,7 +553,7 @@ def create_app(config_object="config"):
|
||||
db.delete(battery)
|
||||
db.commit()
|
||||
flash(f"Battery {label} permanently deleted.", "success")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
return render_template("battery_delete.html", battery=battery)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -516,7 +565,7 @@ def create_app(config_object="config"):
|
||||
ids = request.form.getlist("battery_ids", type=int)
|
||||
if not ids:
|
||||
flash("No batteries selected.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
|
||||
batteries = db.query(Battery).filter(Battery.id.in_(ids)).all()
|
||||
action = request.form.get("action")
|
||||
@@ -545,7 +594,7 @@ def create_app(config_object="config"):
|
||||
new_brand = request.form.get("new_brand", "").strip()
|
||||
if not new_brand:
|
||||
flash("Brand name is required.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
for b in batteries:
|
||||
b.brand = new_brand
|
||||
db.commit()
|
||||
@@ -554,15 +603,15 @@ def create_app(config_object="config"):
|
||||
device_id = request.form.get("device_id", type=int)
|
||||
if not device_id:
|
||||
flash("Please select a device.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
device = db.get(Device, device_id)
|
||||
if device is None:
|
||||
flash("Device not found.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
|
||||
if device.has_children():
|
||||
flash(f"{device.name} has sub-components; install batteries into those instead.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
|
||||
already_here = [b for b in batteries if b.device_id == device.id]
|
||||
retired_sel = [b for b in batteries if b.is_retired()]
|
||||
@@ -571,7 +620,7 @@ def create_app(config_object="config"):
|
||||
|
||||
if not to_process:
|
||||
flash("No eligible batteries to install.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
|
||||
free_slots = device.battery_slots - device.installed_count()
|
||||
if len(to_process) > free_slots:
|
||||
@@ -580,7 +629,7 @@ def create_app(config_object="config"):
|
||||
f"but {len(to_process)} need installing.",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
|
||||
existing_brands = device.installed_brands()
|
||||
new_brands = set(b.brand for b in to_process)
|
||||
@@ -609,10 +658,10 @@ def create_app(config_object="config"):
|
||||
allowed = {"brand", "storage_location"}
|
||||
if field_name not in allowed:
|
||||
flash("Invalid field.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
if field_name == "brand" and not field_value:
|
||||
flash("Brand name is required.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
for b in batteries:
|
||||
setattr(b, field_name, field_value)
|
||||
db.commit()
|
||||
@@ -622,7 +671,7 @@ def create_app(config_object="config"):
|
||||
date_val = _parse_date(request.form.get("charged_date", "").strip())
|
||||
if not date_val:
|
||||
flash("A valid date (YYYY-MM-DD) is required.", "error")
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
increment = 1 if request.form.get("increment_cycles") else 0
|
||||
for b in batteries:
|
||||
_record_charge(db, b, date_val, increment, notes=None)
|
||||
@@ -636,7 +685,7 @@ def create_app(config_object="config"):
|
||||
else:
|
||||
flash("Unknown action.", "error")
|
||||
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("battery_list"))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Devices — list
|
||||
@@ -650,7 +699,7 @@ def create_app(config_object="config"):
|
||||
|
||||
@app.route("/device/")
|
||||
def device_list():
|
||||
devices = db.query(Device).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})
|
||||
@@ -733,8 +782,11 @@ def create_app(config_object="config"):
|
||||
form_device_type=request.form.get("device_type", ""),
|
||||
prefill_parent=parent_device), 400
|
||||
|
||||
if db.query(Device).filter_by(name=name).first():
|
||||
flash(f"A device named '{name}' already exists.", "error")
|
||||
parent_key_val = parent_device.id if parent_device else -1
|
||||
if db.query(Device).filter(
|
||||
Device.parent_key == parent_key_val, Device.name == name
|
||||
).first():
|
||||
flash(f"A device named '{name}' already exists here.", "error")
|
||||
return render_template("device_add.html",
|
||||
device_types=device_types,
|
||||
device_locations=device_locations,
|
||||
@@ -744,23 +796,24 @@ def create_app(config_object="config"):
|
||||
form_device_type=request.form.get("device_type", ""),
|
||||
prefill_parent=parent_device), 400
|
||||
|
||||
is_sub = parent_device is not None
|
||||
device = Device(name=name, battery_slots=slots, notes=notes,
|
||||
device_type=device_type, battery_size=battery_size,
|
||||
location=location,
|
||||
parent_id=parent_device.id if parent_device else None)
|
||||
device_type=None if is_sub else device_type,
|
||||
battery_size=battery_size,
|
||||
location=None if is_sub else location,
|
||||
parent_id=parent_device.id if parent_device else None,
|
||||
parent_key=parent_key_val)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
flash(f"Device '{name}' added.", "success")
|
||||
if parent_device:
|
||||
return redirect(url_for("device_detail", device_id=parent_device.id))
|
||||
return redirect(url_for("device_list"))
|
||||
return redirect(url_for("device_detail", device_id=device.id))
|
||||
|
||||
return render_template("device_add.html", device_types=device_types,
|
||||
device_locations=device_locations,
|
||||
device_battery_sizes=device_battery_sizes,
|
||||
prefill_parent=prefill_parent,
|
||||
form_device_type=prefill_parent.device_type if prefill_parent else None,
|
||||
form_location=prefill_parent.location if prefill_parent else None)
|
||||
prefill_parent=prefill_parent)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Devices — detail
|
||||
@@ -772,17 +825,14 @@ def create_app(config_object="config"):
|
||||
if device is None:
|
||||
abort(404)
|
||||
all_devices = db.query(Device).all()
|
||||
brands_q = db.query(Battery.brand).filter(Battery.status == "available")
|
||||
if device.battery_size:
|
||||
brands_q = brands_q.filter(
|
||||
(Battery.size == device.battery_size) | (Battery.size == None)
|
||||
)
|
||||
brands_q = _filter_compatible(
|
||||
db.query(Battery.brand).filter(Battery.status == "available"),
|
||||
device.battery_size,
|
||||
)
|
||||
brands = [r[0] for r in brands_q.distinct().order_by(Battery.brand).all()]
|
||||
avail_q = db.query(Battery).filter_by(status="available")
|
||||
if device.battery_size:
|
||||
avail_q = avail_q.filter(
|
||||
(Battery.size == device.battery_size) | (Battery.size == None)
|
||||
)
|
||||
avail_q = _filter_compatible(
|
||||
db.query(Battery).filter_by(status="available"), device.battery_size
|
||||
)
|
||||
available_batteries = avail_q.order_by(Battery.label).all()
|
||||
device_types = sorted({d.device_type for d in all_devices if d.device_type})
|
||||
device_locations = sorted({d.location for d in all_devices if d.location})
|
||||
@@ -791,9 +841,10 @@ def create_app(config_object="config"):
|
||||
if ha_client.enabled and device.ha_entity_id:
|
||||
ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=1)
|
||||
if ha_live_pct is not None:
|
||||
ha_live_pct = max(0, min(100, ha_live_pct))
|
||||
changed = False
|
||||
for battery in device.batteries:
|
||||
if battery.status == "installed" and battery.battery_percentage != ha_live_pct:
|
||||
for battery in device.installed_batteries():
|
||||
if battery.battery_percentage != ha_live_pct:
|
||||
battery.battery_percentage = ha_live_pct
|
||||
db.add(BatteryPctLog(
|
||||
battery_id=battery.id,
|
||||
@@ -804,6 +855,17 @@ def create_app(config_object="config"):
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
flat_installed = []
|
||||
children_avail = {}
|
||||
if device.has_children():
|
||||
for child in device.children:
|
||||
for b in child.batteries:
|
||||
if b.status == "installed":
|
||||
flat_installed.append((b, child.name, child.id))
|
||||
avail_q = _filter_compatible(
|
||||
db.query(Battery).filter_by(status="available"), child.battery_size
|
||||
)
|
||||
children_avail[child.id] = avail_q.order_by(Battery.label).all()
|
||||
return render_template("device_detail.html", device=device, brands=brands,
|
||||
available_batteries=available_batteries,
|
||||
device_types=device_types,
|
||||
@@ -812,7 +874,9 @@ def create_app(config_object="config"):
|
||||
device_list_all=all_devices,
|
||||
ha_enabled=ha_client.enabled,
|
||||
ha_live_pct=ha_live_pct,
|
||||
logbook_entries=device.logbook_entries)
|
||||
logbook_entries=device.logbook_entries,
|
||||
flat_installed=flat_installed,
|
||||
children_avail=children_avail)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Devices — edit
|
||||
@@ -840,13 +904,20 @@ def create_app(config_object="config"):
|
||||
except ValueError:
|
||||
flash("Battery slots must be a positive integer.", "error")
|
||||
return redirect(url_for("device_detail", device_id=device_id))
|
||||
existing = db.query(Device).filter_by(name=name).first()
|
||||
if existing and existing.id != device_id:
|
||||
flash(f"A device named '{name}' already exists.", "error")
|
||||
# Determine new parent key for scoped uniqueness check
|
||||
parent_id_raw = request.form.get("parent_id", "").strip()
|
||||
new_parent_key_val = int(parent_id_raw) if (parent_id_raw and parent_id_raw.isdigit()) else -1
|
||||
|
||||
existing = db.query(Device).filter(
|
||||
Device.parent_key == new_parent_key_val,
|
||||
Device.name == name,
|
||||
Device.id != device_id,
|
||||
).first()
|
||||
if existing:
|
||||
flash(f"A device named '{name}' already exists here.", "error")
|
||||
return redirect(url_for("device_detail", device_id=device_id))
|
||||
|
||||
# Validate parent_id change
|
||||
parent_id_raw = request.form.get("parent_id", "").strip()
|
||||
if parent_id_raw and parent_id_raw.isdigit():
|
||||
new_parent = db.get(Device, int(parent_id_raw))
|
||||
if not new_parent:
|
||||
@@ -855,6 +926,9 @@ def create_app(config_object="config"):
|
||||
if new_parent.id == device_id:
|
||||
flash("A device cannot be its own parent.", "error")
|
||||
return redirect(url_for("device_detail", device_id=device_id))
|
||||
if device.has_children():
|
||||
flash("A device with sub-components cannot itself become a sub-component.", "error")
|
||||
return redirect(url_for("device_detail", device_id=device_id))
|
||||
if new_parent.is_subcomponent():
|
||||
flash("Cannot nest sub-components more than one level deep.", "error")
|
||||
return redirect(url_for("device_detail", device_id=device_id))
|
||||
@@ -866,12 +940,19 @@ def create_app(config_object="config"):
|
||||
)
|
||||
return redirect(url_for("device_detail", device_id=device_id))
|
||||
device.parent_id = new_parent.id
|
||||
device.parent_key = new_parent.id
|
||||
else:
|
||||
device.parent_id = None
|
||||
device.parent_key = -1
|
||||
|
||||
device.name = name
|
||||
device.notes = notes
|
||||
device.device_type = device_type
|
||||
if device.is_subcomponent():
|
||||
device.device_type = None
|
||||
device.location = None
|
||||
else:
|
||||
device.device_type = device_type
|
||||
device.location = request.form.get("location", "").strip() or None
|
||||
if device.has_children():
|
||||
device.battery_slots = 0
|
||||
device.battery_size = None
|
||||
@@ -880,7 +961,6 @@ def create_app(config_object="config"):
|
||||
new_battery_size = request.form.get("battery_size", "").strip() or None
|
||||
if new_battery_size is not None:
|
||||
device.battery_size = new_battery_size
|
||||
device.location = request.form.get("location", "").strip() or None
|
||||
device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None
|
||||
db.commit()
|
||||
flash("Device updated.", "success")
|
||||
@@ -928,11 +1008,10 @@ def create_app(config_object="config"):
|
||||
|
||||
# Validate availability before writing anything
|
||||
for brand, qty in pairs:
|
||||
avail_q = db.query(func.count(Battery.id)).filter_by(brand=brand, status="available")
|
||||
if device.battery_size:
|
||||
avail_q = avail_q.filter(
|
||||
(Battery.size == device.battery_size) | (Battery.size == None)
|
||||
)
|
||||
avail_q = _filter_compatible(
|
||||
db.query(func.count(Battery.id)).filter_by(brand=brand, status="available"),
|
||||
device.battery_size,
|
||||
)
|
||||
available_count = avail_q.scalar()
|
||||
if available_count < qty:
|
||||
flash(
|
||||
@@ -944,11 +1023,10 @@ def create_app(config_object="config"):
|
||||
# All checks passed — perform installs
|
||||
total_installed = 0
|
||||
for brand, qty in pairs:
|
||||
batch_q = db.query(Battery).filter_by(brand=brand, status="available")
|
||||
if device.battery_size:
|
||||
batch_q = batch_q.filter(
|
||||
(Battery.size == device.battery_size) | (Battery.size == None)
|
||||
)
|
||||
batch_q = _filter_compatible(
|
||||
db.query(Battery).filter_by(brand=brand, status="available"),
|
||||
device.battery_size,
|
||||
)
|
||||
batch = batch_q.order_by(Battery.id).limit(qty).all()
|
||||
for b in batch:
|
||||
b.status = "installed"
|
||||
@@ -1050,10 +1128,7 @@ def create_app(config_object="config"):
|
||||
f"Unassigned {count} batter{'y' if count == 1 else 'ies'} from {device.name}.",
|
||||
"success",
|
||||
)
|
||||
nxt = request.form.get("next", "")
|
||||
if nxt.startswith("/"):
|
||||
return redirect(nxt)
|
||||
return redirect(url_for("device_list"))
|
||||
return redirect(_safe_next(url_for("device_list")))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Devices — batch install specific batteries
|
||||
@@ -1107,7 +1182,7 @@ def create_app(config_object="config"):
|
||||
return redirect(url_for("device_detail", device_id=device_id))
|
||||
increment = 1 if request.form.get("increment_cycles") else 0
|
||||
notes = request.form.get("notes", "").strip() or None
|
||||
installed = [b for b in device.batteries if b.status == "installed"]
|
||||
installed = device.installed_batteries()
|
||||
if not installed:
|
||||
flash("No installed batteries to log.", "warning")
|
||||
return redirect(url_for("device_detail", device_id=device_id))
|
||||
@@ -1148,11 +1223,13 @@ def create_app(config_object="config"):
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf)
|
||||
w.writerow(["id", "name", "battery_slots", "installed_count",
|
||||
"device_type", "battery_size", "location", "ha_entity_id", "notes"])
|
||||
"device_type", "battery_size", "location", "ha_entity_id", "notes",
|
||||
"parent_id", "parent_name"])
|
||||
for d in rows:
|
||||
w.writerow([d.id, d.name, d.battery_slots, d.installed_count(),
|
||||
d.device_type or "", d.battery_size or "",
|
||||
d.location or "", d.ha_entity_id or "", d.notes or ""])
|
||||
d.location or "", d.ha_entity_id or "", d.notes or "",
|
||||
d.parent_id or "", d.parent.name if d.parent else ""])
|
||||
return buf.getvalue()
|
||||
|
||||
def _charge_logs_csv():
|
||||
@@ -1160,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():
|
||||
@@ -1180,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")
|
||||
@@ -1251,14 +1328,16 @@ def create_app(config_object="config"):
|
||||
{"id": d.id, "name": d.name, "battery_slots": d.battery_slots,
|
||||
"installed_count": d.installed_count(), "device_type": d.device_type,
|
||||
"battery_size": d.battery_size, "location": d.location,
|
||||
"ha_entity_id": d.ha_entity_id, "notes": d.notes}
|
||||
"ha_entity_id": d.ha_entity_id, "notes": d.notes,
|
||||
"parent_id": d.parent_id,
|
||||
"parent_name": d.parent.name if d.parent else None}
|
||||
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,
|
||||
@@ -1267,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",
|
||||
@@ -1321,26 +1400,33 @@ def create_app(config_object="config"):
|
||||
|
||||
try:
|
||||
# --- devices ---
|
||||
for d in data.get("devices", []):
|
||||
def _import_device(d, new_parent_id):
|
||||
nonlocal devices_created, devices_skipped
|
||||
old_id = d.get("id")
|
||||
name = (d.get("name") or "").strip()
|
||||
if not name:
|
||||
devices_skipped += 1
|
||||
continue
|
||||
existing = db.query(Device).filter_by(name=name).first()
|
||||
return
|
||||
parent_key = new_parent_id if new_parent_id is not None else -1
|
||||
existing = db.query(Device).filter(
|
||||
Device.parent_key == parent_key, Device.name == name
|
||||
).first()
|
||||
if existing:
|
||||
if old_id is not None:
|
||||
device_id_map[old_id] = existing.id
|
||||
devices_skipped += 1
|
||||
else:
|
||||
slots = d.get("battery_slots")
|
||||
new_dev = Device(
|
||||
name = name,
|
||||
battery_slots = d.get("battery_slots") or 1,
|
||||
battery_slots = slots if slots is not None else 1,
|
||||
device_type = d.get("device_type") or None,
|
||||
battery_size = d.get("battery_size") or "",
|
||||
battery_size = d.get("battery_size") or None,
|
||||
location = d.get("location") or None,
|
||||
ha_entity_id = d.get("ha_entity_id") or None,
|
||||
notes = d.get("notes") or None,
|
||||
parent_id = new_parent_id,
|
||||
parent_key = parent_key,
|
||||
)
|
||||
db.add(new_dev)
|
||||
db.flush()
|
||||
@@ -1348,6 +1434,17 @@ def create_app(config_object="config"):
|
||||
device_id_map[old_id] = new_dev.id
|
||||
devices_created += 1
|
||||
|
||||
# Two passes so sub-components can resolve their parent's new id
|
||||
# regardless of ordering in the payload. A sub whose parent is
|
||||
# missing from the payload is imported as top-level.
|
||||
device_rows = data.get("devices", [])
|
||||
for d in device_rows:
|
||||
if d.get("parent_id") is None:
|
||||
_import_device(d, None)
|
||||
for d in device_rows:
|
||||
if d.get("parent_id") is not None:
|
||||
_import_device(d, device_id_map.get(d.get("parent_id")))
|
||||
|
||||
# --- batteries ---
|
||||
for b in data.get("batteries", []):
|
||||
old_id = b.get("id")
|
||||
|
||||
+4
-3
@@ -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:
|
||||
@@ -49,8 +49,9 @@ class HaPoller:
|
||||
for device in devices:
|
||||
pct = self._client.get_state(device.ha_entity_id)
|
||||
if pct is not None:
|
||||
for battery in device.batteries:
|
||||
if battery.status == "installed" and battery.battery_percentage != pct:
|
||||
pct = max(0, min(100, pct))
|
||||
for battery in device.installed_batteries():
|
||||
if battery.battery_percentage != pct:
|
||||
battery.battery_percentage = pct
|
||||
session.add(BatteryPctLog(
|
||||
battery_id=battery.id,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table, UniqueConstraint
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -25,7 +24,7 @@ class Device(Base):
|
||||
__tablename__ = "device"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, unique=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
battery_slots = Column(Integer, nullable=False, default=1)
|
||||
device_type = Column(String(50), nullable=True)
|
||||
battery_size = Column(String(20), nullable=True) # AA, AAA, 9V, CR2032 …; null for parent-only devices
|
||||
@@ -33,6 +32,11 @@ class Device(Base):
|
||||
notes = Column(Text, nullable=True)
|
||||
ha_entity_id = Column(String(100), nullable=True) # e.g. "sensor.tv_remote_battery"
|
||||
parent_id = Column(Integer, ForeignKey("device.id", ondelete="SET NULL"), nullable=True)
|
||||
parent_key = Column(Integer, nullable=False, default=-1) # -1 for top-level, parent_id for sub-components
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("parent_key", "name", name="uq_device_parent_key_name"),
|
||||
)
|
||||
|
||||
batteries = relationship("Battery", back_populates="device")
|
||||
logbook_entries = relationship(
|
||||
@@ -71,6 +75,26 @@ class Device(Base):
|
||||
def is_subcomponent(self):
|
||||
return self.parent_id is not None
|
||||
|
||||
def can_have_subcomponents(self):
|
||||
return self.parent_id is None and self.battery_slots == 0
|
||||
|
||||
def effective_installed_count(self):
|
||||
if self.has_children():
|
||||
return sum(c.installed_count() for c in self.children)
|
||||
return self.installed_count()
|
||||
|
||||
def effective_slots(self):
|
||||
if self.has_children():
|
||||
return sum(c.battery_slots for c in self.children)
|
||||
return self.battery_slots
|
||||
|
||||
def installed_batteries(self):
|
||||
"""Installed batteries on this device, including its sub-components."""
|
||||
bats = [b for b in self.batteries if b.status == "installed"]
|
||||
for child in self.children:
|
||||
bats.extend(b for b in child.batteries if b.status == "installed")
|
||||
return bats
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Device {self.name}>"
|
||||
|
||||
|
||||
+3
-1
@@ -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."""
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
sbin/migrate_parent_key.py
|
||||
==========================
|
||||
Adds parent_key column, changes uniqueness from UNIQUE(name) to
|
||||
UNIQUE(parent_key, name), and clears device_type/location for sub-components.
|
||||
|
||||
SQLite: recreates the device table (required to change constraints).
|
||||
MariaDB: uses ALTER TABLE. Check the index name with:
|
||||
SHOW INDEX FROM device WHERE Column_name = 'name';
|
||||
|
||||
Usage:
|
||||
python sbin/migrate_parent_key.py # SQLite (batteries.db)
|
||||
MARIADB_URL='mysql+...' python sbin/migrate_parent_key.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def snapshot(src: Path) -> None:
|
||||
dst = src.parent / f"{src.name}.{date.today().isoformat()}.snapshot"
|
||||
shutil.copy2(src, dst)
|
||||
print(f"Snapshot: {dst.name}")
|
||||
|
||||
|
||||
def migrate_sqlite() -> None:
|
||||
import sqlite3
|
||||
|
||||
db_path = REPO_ROOT / "batteries.db"
|
||||
if not db_path.exists():
|
||||
print("batteries.db not found — nothing to migrate.")
|
||||
return
|
||||
|
||||
snapshot(db_path)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
|
||||
# Check if parent_key already exists (idempotency)
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(device)")}
|
||||
if "parent_key" in cols:
|
||||
print("parent_key already present — running data-only fixup.")
|
||||
conn.execute("UPDATE device SET parent_key = COALESCE(parent_id, -1)")
|
||||
conn.execute("UPDATE device SET device_type = NULL, location = NULL WHERE parent_id IS NOT NULL")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Done.")
|
||||
return
|
||||
|
||||
conn.execute("ALTER TABLE device RENAME TO device_old")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE device (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
battery_slots INTEGER NOT NULL DEFAULT 1,
|
||||
device_type VARCHAR(50),
|
||||
battery_size VARCHAR(20),
|
||||
location VARCHAR(100),
|
||||
notes TEXT,
|
||||
ha_entity_id VARCHAR(100),
|
||||
parent_id INTEGER REFERENCES device(id) ON DELETE SET NULL,
|
||||
parent_key INTEGER NOT NULL DEFAULT -1,
|
||||
CONSTRAINT uq_device_parent_key_name UNIQUE (parent_key, name)
|
||||
)
|
||||
""")
|
||||
|
||||
conn.execute("""
|
||||
INSERT INTO device
|
||||
(id, name, battery_slots, device_type, battery_size, location,
|
||||
notes, ha_entity_id, parent_id, parent_key)
|
||||
SELECT
|
||||
id, name, battery_slots,
|
||||
CASE WHEN parent_id IS NOT NULL THEN NULL ELSE device_type END,
|
||||
battery_size,
|
||||
CASE WHEN parent_id IS NOT NULL THEN NULL ELSE location END,
|
||||
notes, ha_entity_id, parent_id,
|
||||
COALESCE(parent_id, -1)
|
||||
FROM device_old
|
||||
""")
|
||||
|
||||
conn.execute("DROP TABLE device_old")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.commit()
|
||||
|
||||
count = conn.execute("SELECT COUNT(*) FROM device").fetchone()[0]
|
||||
print(f"SQLite migration complete — {count} device(s) migrated.")
|
||||
conn.close()
|
||||
|
||||
|
||||
def migrate_mariadb(url: str) -> None:
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
engine = create_engine(url)
|
||||
with engine.connect() as conn:
|
||||
# Add column (skip if already present)
|
||||
try:
|
||||
conn.execute(text(
|
||||
"ALTER TABLE device ADD COLUMN parent_key INT NOT NULL DEFAULT -1"
|
||||
))
|
||||
except Exception:
|
||||
print("parent_key column already exists — skipping ADD COLUMN.")
|
||||
|
||||
conn.execute(text("UPDATE device SET parent_key = COALESCE(parent_id, -1)"))
|
||||
conn.execute(text(
|
||||
"UPDATE device SET device_type = NULL, location = NULL WHERE parent_id IS NOT NULL"
|
||||
))
|
||||
|
||||
# Drop old UNIQUE index on name — check actual name first:
|
||||
# SHOW INDEX FROM device WHERE Column_name = 'name';
|
||||
# Common names: 'name' or 'ix_device_name'
|
||||
for idx_name in ("name", "ix_device_name"):
|
||||
try:
|
||||
conn.execute(text(f"ALTER TABLE device DROP INDEX `{idx_name}`"))
|
||||
print(f"Dropped index '{idx_name}'.")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
conn.execute(text(
|
||||
"ALTER TABLE device ADD CONSTRAINT uq_device_parent_key_name "
|
||||
"UNIQUE (parent_key, name)"
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
print("MariaDB migration complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mariadb_url = os.environ.get("MARIADB_URL", "").strip()
|
||||
if mariadb_url:
|
||||
migrate_mariadb(mariadb_url)
|
||||
else:
|
||||
migrate_sqlite()
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Shared front-end behavior, loaded with `defer` from base.html.
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
}
|
||||
|
||||
// Inject CSRF token into all POST forms
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
var token = meta ? meta.content : '';
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
if (form.method.toLowerCase() === 'post') {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
|
||||
form.appendChild(inp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// "Select from list or type new" pattern on add/edit forms
|
||||
function metaSelectChanged(sel, inputId) {
|
||||
var input = document.getElementById(inputId);
|
||||
if (sel.value === '__new__') {
|
||||
input.style.display = '';
|
||||
input.value = '';
|
||||
input.focus();
|
||||
} else {
|
||||
input.style.display = 'none';
|
||||
input.value = sel.value;
|
||||
}
|
||||
// battery_add defines updateLabelPreview to live-preview generated labels
|
||||
if ((inputId === 'brand' || inputId === 'size') && typeof updateLabelPreview === 'function') {
|
||||
updateLabelPreview();
|
||||
}
|
||||
}
|
||||
|
||||
(function() {
|
||||
var modal = document.getElementById('confirm-modal');
|
||||
var msgEl = document.getElementById('confirm-modal-msg');
|
||||
var okBtn = document.getElementById('confirm-modal-ok');
|
||||
var cancelBtn = document.getElementById('confirm-modal-cancel');
|
||||
var _cb = null;
|
||||
|
||||
window.showConfirm = function(msg, onOk, okLabel, okClass) {
|
||||
msgEl.textContent = msg;
|
||||
okBtn.textContent = okLabel || 'Confirm';
|
||||
okBtn.className = 'btn ' + (okClass || 'btn-danger');
|
||||
modal.classList.add('open');
|
||||
_cb = onOk;
|
||||
cancelBtn.focus();
|
||||
};
|
||||
|
||||
function closeModal() { modal.classList.remove('open'); _cb = null; }
|
||||
|
||||
okBtn.addEventListener('click', function() {
|
||||
var cb = _cb; closeModal(); if (cb) cb();
|
||||
});
|
||||
cancelBtn.addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
|
||||
});
|
||||
|
||||
// Apply URL params as initial filter state on battery_list and device_list
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var statusParam = params.get('status');
|
||||
if (statusParam !== null) {
|
||||
var statusSel = document.getElementById('filter-status');
|
||||
if (statusSel && typeof applyFilters === 'function') {
|
||||
statusSel.value = statusParam;
|
||||
applyFilters();
|
||||
}
|
||||
}
|
||||
var fillParam = params.get('fill');
|
||||
if (fillParam !== null) {
|
||||
var fillSel = document.getElementById('filter-fill');
|
||||
if (fillSel && typeof applyDeviceFilters === 'function') {
|
||||
fillSel.value = fillParam;
|
||||
applyDeviceFilters();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Global handler: forms with data-confirm attribute
|
||||
document.addEventListener('submit', function(e) {
|
||||
var form = e.target;
|
||||
var msg = form.dataset.confirm;
|
||||
if (!msg || form.dataset.confirmed) return;
|
||||
e.preventDefault();
|
||||
var okLabel = form.dataset.confirmOk || 'Confirm';
|
||||
var okClass = form.dataset.confirmClass || 'btn-danger';
|
||||
window.showConfirm(msg, function() {
|
||||
form.dataset.confirmed = '1';
|
||||
form.submit();
|
||||
}, okLabel, okClass);
|
||||
});
|
||||
}());
|
||||
@@ -2,9 +2,10 @@
|
||||
"name": "Battery Tracker",
|
||||
"short_name": "Batteries",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#2563eb",
|
||||
"theme_color": "#1e40af",
|
||||
"icons": [
|
||||
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
/* ─── Color variables (light mode) ──────────────────────────────── */
|
||||
:root {
|
||||
--bg-body: #f5f5f5;
|
||||
--bg-card: #ffffff;
|
||||
--bg-th: #f1f5f9;
|
||||
--bg-hover: #f8fafc;
|
||||
--bg-input: #ffffff;
|
||||
--bg-toolbar: #f1f5f9;
|
||||
--bg-picker: #ffffff;
|
||||
|
||||
--text-body: #222222;
|
||||
--text-muted: #6b7280;
|
||||
--text-label: #374151;
|
||||
--text-th: #64748b;
|
||||
--text-h1: #1e293b;
|
||||
--text-h2: #334155;
|
||||
--text-warning: #b45309;
|
||||
--text-danger: #dc2626;
|
||||
|
||||
--border: #e2e8f0;
|
||||
--border-input: #d1d5db;
|
||||
--link: #2563eb;
|
||||
--shadow-card: 0 1px 3px rgba(0,0,0,.1);
|
||||
|
||||
--btn-secondary-bg: #e2e8f0;
|
||||
--btn-secondary-text: #334155;
|
||||
--btn-secondary-hover: #cbd5e1;
|
||||
|
||||
--badge-available-bg: #dcfce7; --badge-available-text: #166534;
|
||||
--badge-installed-bg: #dbeafe; --badge-installed-text: #1e40af;
|
||||
--badge-retired-bg: #f1f5f9; --badge-retired-text: #64748b;
|
||||
--badge-warning-bg: #fef9c3; --badge-warning-text: #854d0e;
|
||||
|
||||
--flash-success-bg: #dcfce7; --flash-success-text: #166534; --flash-success-border: #86efac;
|
||||
--flash-error-bg: #fee2e2; --flash-error-text: #991b1b; --flash-error-border: #fca5a5;
|
||||
--flash-warning-bg: #fef9c3; --flash-warning-text: #854d0e; --flash-warning-border: #fde047;
|
||||
|
||||
--health-good: #166534;
|
||||
--health-warn: #92400e;
|
||||
--health-bad: #991b1b;
|
||||
--count-available: #166534;
|
||||
--count-installed: #1e40af;
|
||||
--count-retired: #64748b;
|
||||
}
|
||||
|
||||
/* Confirmation modal */
|
||||
#confirm-modal { display:none; position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.45); align-items:center; justify-content:center; }
|
||||
#confirm-modal.open { display:flex; }
|
||||
#confirm-modal-box { background:var(--bg-card); border-radius:8px; padding:1.5rem; max-width:400px; width:calc(100% - 2rem); box-shadow:0 8px 24px rgba(0,0,0,.25); }
|
||||
#confirm-modal-msg { margin-bottom:1.25rem; color:var(--text-body); font-size:0.95rem; line-height:1.5; }
|
||||
#confirm-modal-actions { display:flex; gap:0.75rem; justify-content:flex-end; }
|
||||
|
||||
/* ─── Dark mode variables ────────────────────────────────────────── */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-body: #0f172a;
|
||||
--bg-card: #1e293b;
|
||||
--bg-th: #162032;
|
||||
--bg-hover: #243044;
|
||||
--bg-input: #1e293b;
|
||||
--bg-toolbar: #162032;
|
||||
--bg-picker: #1e293b;
|
||||
|
||||
--text-body: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
--text-label: #cbd5e1;
|
||||
--text-th: #94a3b8;
|
||||
--text-h1: #f1f5f9;
|
||||
--text-h2: #cbd5e1;
|
||||
--text-warning: #fbbf24;
|
||||
--text-danger: #f87171;
|
||||
|
||||
--border: #334155;
|
||||
--border-input: #475569;
|
||||
--link: #60a5fa;
|
||||
--shadow-card: 0 1px 3px rgba(0,0,0,.5);
|
||||
|
||||
--btn-secondary-bg: #334155;
|
||||
--btn-secondary-text: #cbd5e1;
|
||||
--btn-secondary-hover: #475569;
|
||||
|
||||
--badge-available-bg: #14532d; --badge-available-text: #86efac;
|
||||
--badge-installed-bg: #1e3a8a; --badge-installed-text: #93c5fd;
|
||||
--badge-retired-bg: #273449; --badge-retired-text: #94a3b8;
|
||||
--badge-warning-bg: #451a03; --badge-warning-text: #fde68a;
|
||||
|
||||
--flash-success-bg: #14532d; --flash-success-text: #86efac; --flash-success-border: #166534;
|
||||
--flash-error-bg: #450a0a; --flash-error-text: #fca5a5; --flash-error-border: #991b1b;
|
||||
--flash-warning-bg: #451a03; --flash-warning-text: #fde68a; --flash-warning-border: #92400e;
|
||||
|
||||
--health-good: #86efac;
|
||||
--health-warn: #fde68a;
|
||||
--health-bad: #fca5a5;
|
||||
--count-available: #86efac;
|
||||
--count-installed: #93c5fd;
|
||||
--count-retired: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; font-size: 15px; background: var(--bg-body); color: var(--text-body); }
|
||||
a { color: var(--link); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
nav .brand { font-weight: 700; font-size: 1.1rem; margin-right: auto; color: #fff; text-decoration: none; }
|
||||
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; }
|
||||
|
||||
/* Flash messages */
|
||||
.flash { padding: 0.6rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||
.flash.success { background: var(--flash-success-bg); color: var(--flash-success-text); border: 1px solid var(--flash-success-border); }
|
||||
.flash.error { background: var(--flash-error-bg); color: var(--flash-error-text); border: 1px solid var(--flash-error-border); }
|
||||
.flash.warning { background: var(--flash-warning-bg); color: var(--flash-warning-text); border: 1px solid var(--flash-warning-border); }
|
||||
|
||||
/* Cards / boxes */
|
||||
.card { background: var(--bg-card); border-radius: 6px; box-shadow: var(--shadow-card); 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: var(--bg-th); font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-th); border-bottom: 2px solid var(--border); }
|
||||
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--bg-hover); }
|
||||
|
||||
/* Status badges */
|
||||
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge-available { background: var(--badge-available-bg); color: var(--badge-available-text); }
|
||||
.badge-installed { background: var(--badge-installed-bg); color: var(--badge-installed-text); }
|
||||
.badge-retired { background: var(--badge-retired-bg); color: var(--badge-retired-text); }
|
||||
.badge-warning { background: var(--badge-warning-bg); color: var(--badge-warning-text); }
|
||||
|
||||
/* Clickable stat cards on home page */
|
||||
.stat-link { display: block; flex: 1; min-width: 120px; text-decoration: none; }
|
||||
.stat-link:hover { text-decoration: none; }
|
||||
.stat-link:hover .card { box-shadow: 0 2px 8px rgba(0,0,0,.18); }
|
||||
|
||||
/* 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: var(--btn-secondary-bg); color: var(--btn-secondary-text); }
|
||||
.btn-secondary:hover { background: var(--btn-secondary-hover); }
|
||||
.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: var(--text-label); }
|
||||
input[type=text], input[type=number], input[type=date], select, textarea {
|
||||
width: 100%; padding: 0.45rem 0.65rem; border: 1px solid var(--border-input);
|
||||
border-radius: 4px; font-size: 0.9rem; font-family: inherit;
|
||||
background: var(--bg-input); color: var(--text-body);
|
||||
}
|
||||
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: var(--text-h1); }
|
||||
h2 { font-size: 1.15rem; margin-bottom: 0.75rem; color: var(--text-h2); }
|
||||
|
||||
/* Inline form (for POST buttons in tables) */
|
||||
form.inline { display: inline; }
|
||||
|
||||
/* Text utilities */
|
||||
.text-warning { color: var(--text-warning); font-size: 0.8rem; }
|
||||
.text-danger { color: var(--text-danger); }
|
||||
.text-muted { color: var(--text-muted); font-size: 0.85rem; }
|
||||
|
||||
/* Battery health classes */
|
||||
.health-good { color: var(--health-good); }
|
||||
.health-warn { color: var(--health-warn); }
|
||||
.health-bad { color: var(--health-bad); }
|
||||
|
||||
/* ─── Dark mode overrides for inline-styled elements ─────────────── */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
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; }
|
||||
#col-picker-panel { background: var(--bg-picker) !important; border-color: var(--border) !important; }
|
||||
#col-picker-panel > div:first-child { color: var(--text-th) !important; }
|
||||
|
||||
#filter-bar select, #filter-bar input,
|
||||
#bulk-toolbar select, #bulk-toolbar input[type=text],
|
||||
#device-filter-bar select, #device-filter-bar input {
|
||||
background: var(--bg-input) !important;
|
||||
color: var(--text-body) !important;
|
||||
border-color: var(--border-input) !important;
|
||||
}
|
||||
.responsive-table td select {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-body);
|
||||
border-color: var(--border-input);
|
||||
}
|
||||
.device-option {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
#install-grid select, #install-grid input {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-body);
|
||||
border-color: var(--border-input);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Mobile responsive ─────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
|
||||
/* Nav: compact on small screens */
|
||||
nav { padding: 0.5rem 0.75rem; gap: 0.35rem; }
|
||||
nav a { font-size: 0.8rem; padding: 0.2rem 0.35rem; }
|
||||
nav .brand { font-size: 1rem; }
|
||||
|
||||
/* Tap targets */
|
||||
.btn { padding: 0.5rem 0.9rem; }
|
||||
.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.825rem; }
|
||||
|
||||
/* Collapse 2-col form grids */
|
||||
.form-grid-2col { grid-template-columns: 1fr !important; }
|
||||
|
||||
/* Card-style table rows */
|
||||
.responsive-table thead { display: none; }
|
||||
.responsive-table tr {
|
||||
display: block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.responsive-table tr:hover td { background: transparent; }
|
||||
.responsive-table td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-bottom: 1px solid var(--bg-th);
|
||||
border-top: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.responsive-table td:last-child { border-bottom: none; }
|
||||
.responsive-table td:empty { display: none; }
|
||||
.responsive-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: var(--text-th);
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Checkbox column: no label prefix, left-aligned */
|
||||
.responsive-table td[data-label=""] {
|
||||
justify-content: flex-start;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
/* Actions column: allow wrapping, override nowrap */
|
||||
.responsive-table td[data-label="Actions"] {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
white-space: normal !important;
|
||||
}
|
||||
.responsive-table td[data-label="Actions"] select {
|
||||
width: 100%;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Filter bar: full-width controls with larger tap targets */
|
||||
#filter-bar select,
|
||||
#filter-bar input[type=text] { flex: 1 1 100%; width: 100% !important; padding: 0.5rem 0.65rem !important; font-size: 0.9rem !important; }
|
||||
|
||||
/* Bulk toolbar: prevent text inputs overflowing */
|
||||
#bulk-toolbar input[type=text] { width: 100% !important; }
|
||||
|
||||
/* Install grid on device_detail */
|
||||
#install-grid { grid-template-columns: 1fr !important; max-width: 100% !important; }
|
||||
|
||||
/* Column picker: keep panel inside viewport */
|
||||
#col-picker-panel { left: 0; right: auto !important; max-width: calc(100vw - 2rem); }
|
||||
|
||||
/* form-actions: stack buttons full-width */
|
||||
.form-actions { flex-direction: column; align-items: stretch; }
|
||||
.form-actions .btn,
|
||||
.form-actions button { width: 100%; text-align: center; }
|
||||
|
||||
/* ── Mobile: html bg matches nav so status bar area has no color gap ── */
|
||||
html { background: #1e40af; }
|
||||
|
||||
/* ── Mobile: title-bar nav fills behind status bar, links hidden ── */
|
||||
nav:not(.bottom-nav) {
|
||||
padding-top: calc(0.4rem + env(safe-area-inset-top));
|
||||
padding-bottom: 0.4rem;
|
||||
justify-content: center;
|
||||
}
|
||||
nav:not(.bottom-nav) a:not(.brand) { display: none; }
|
||||
nav:not(.bottom-nav) .brand { margin-right: 0; font-size: 0.95rem; }
|
||||
body { padding-bottom: 4.5rem; }
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
display: flex;
|
||||
background: #1e40af;
|
||||
border-top: 1px solid #1d4ed8;
|
||||
z-index: 100;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.bottom-nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.25rem 0.4rem;
|
||||
color: #bfdbfe;
|
||||
text-decoration: none;
|
||||
font-size: 0.65rem;
|
||||
gap: 0.2rem;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.bottom-nav-item.active { color: #fff; }
|
||||
.bottom-nav-item svg { display: block; }
|
||||
}
|
||||
|
||||
/* ── Desktop: hide bottom nav ── */
|
||||
@media (min-width: 641px) {
|
||||
.bottom-nav { display: none; }
|
||||
}
|
||||
|
||||
/* ── Dark mode: bottom nav + html background ── */
|
||||
@media (prefers-color-scheme: dark) and (max-width: 640px) {
|
||||
html { background: #0c1a3b; }
|
||||
.bottom-nav { background: #0c1a3b; border-top-color: #1e3a8a; }
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;min-height:44px;cursor:pointer;">
|
||||
<input type="radio" name="device_id" value="{{ device.id }}" style="cursor:pointer;">
|
||||
<span>
|
||||
<strong>{{ device.name }}</strong>
|
||||
{% if device.parent %}<span class="text-muted">{{ device.parent.name }} / </span>{% endif %}<strong>{{ device.name }}</strong>
|
||||
<span class="text-muted">({{ device.installed_count() }}/{{ device.battery_slots }} slots used)</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
+31
-382
@@ -5,324 +5,22 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Battery Tracker{% endblock %}</title>
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<meta name="theme-color" content="#1e40af" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0c1a3b" media="(prefers-color-scheme: dark)">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Batteries">
|
||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<style>
|
||||
/* ─── Color variables (light mode) ──────────────────────────────── */
|
||||
:root {
|
||||
--bg-body: #f5f5f5;
|
||||
--bg-card: #ffffff;
|
||||
--bg-th: #f1f5f9;
|
||||
--bg-hover: #f8fafc;
|
||||
--bg-input: #ffffff;
|
||||
--bg-toolbar: #f1f5f9;
|
||||
--bg-picker: #ffffff;
|
||||
|
||||
--text-body: #222222;
|
||||
--text-muted: #6b7280;
|
||||
--text-label: #374151;
|
||||
--text-th: #64748b;
|
||||
--text-h1: #1e293b;
|
||||
--text-h2: #334155;
|
||||
--text-warning: #b45309;
|
||||
--text-danger: #dc2626;
|
||||
|
||||
--border: #e2e8f0;
|
||||
--border-input: #d1d5db;
|
||||
--link: #2563eb;
|
||||
--shadow-card: 0 1px 3px rgba(0,0,0,.1);
|
||||
|
||||
--btn-secondary-bg: #e2e8f0;
|
||||
--btn-secondary-text: #334155;
|
||||
--btn-secondary-hover: #cbd5e1;
|
||||
|
||||
--badge-available-bg: #dcfce7; --badge-available-text: #166534;
|
||||
--badge-installed-bg: #dbeafe; --badge-installed-text: #1e40af;
|
||||
--badge-retired-bg: #f1f5f9; --badge-retired-text: #64748b;
|
||||
--badge-warning-bg: #fef9c3; --badge-warning-text: #854d0e;
|
||||
|
||||
--flash-success-bg: #dcfce7; --flash-success-text: #166534; --flash-success-border: #86efac;
|
||||
--flash-error-bg: #fee2e2; --flash-error-text: #991b1b; --flash-error-border: #fca5a5;
|
||||
--flash-warning-bg: #fef9c3; --flash-warning-text: #854d0e; --flash-warning-border: #fde047;
|
||||
|
||||
--health-good: #166534;
|
||||
--health-warn: #92400e;
|
||||
--health-bad: #991b1b;
|
||||
--count-available: #166534;
|
||||
--count-installed: #1e40af;
|
||||
--count-retired: #64748b;
|
||||
}
|
||||
|
||||
/* Confirmation modal */
|
||||
#confirm-modal { display:none; position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.45); align-items:center; justify-content:center; }
|
||||
#confirm-modal.open { display:flex; }
|
||||
#confirm-modal-box { background:var(--bg-card); border-radius:8px; padding:1.5rem; max-width:400px; width:calc(100% - 2rem); box-shadow:0 8px 24px rgba(0,0,0,.25); }
|
||||
#confirm-modal-msg { margin-bottom:1.25rem; color:var(--text-body); font-size:0.95rem; line-height:1.5; }
|
||||
#confirm-modal-actions { display:flex; gap:0.75rem; justify-content:flex-end; }
|
||||
|
||||
/* ─── Dark mode variables ────────────────────────────────────────── */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-body: #0f172a;
|
||||
--bg-card: #1e293b;
|
||||
--bg-th: #162032;
|
||||
--bg-hover: #243044;
|
||||
--bg-input: #1e293b;
|
||||
--bg-toolbar: #162032;
|
||||
--bg-picker: #1e293b;
|
||||
|
||||
--text-body: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
--text-label: #cbd5e1;
|
||||
--text-th: #94a3b8;
|
||||
--text-h1: #f1f5f9;
|
||||
--text-h2: #cbd5e1;
|
||||
--text-warning: #fbbf24;
|
||||
--text-danger: #f87171;
|
||||
|
||||
--border: #334155;
|
||||
--border-input: #475569;
|
||||
--link: #60a5fa;
|
||||
--shadow-card: 0 1px 3px rgba(0,0,0,.5);
|
||||
|
||||
--btn-secondary-bg: #334155;
|
||||
--btn-secondary-text: #cbd5e1;
|
||||
--btn-secondary-hover: #475569;
|
||||
|
||||
--badge-available-bg: #14532d; --badge-available-text: #86efac;
|
||||
--badge-installed-bg: #1e3a8a; --badge-installed-text: #93c5fd;
|
||||
--badge-retired-bg: #273449; --badge-retired-text: #94a3b8;
|
||||
--badge-warning-bg: #451a03; --badge-warning-text: #fde68a;
|
||||
|
||||
--flash-success-bg: #14532d; --flash-success-text: #86efac; --flash-success-border: #166534;
|
||||
--flash-error-bg: #450a0a; --flash-error-text: #fca5a5; --flash-error-border: #991b1b;
|
||||
--flash-warning-bg: #451a03; --flash-warning-text: #fde68a; --flash-warning-border: #92400e;
|
||||
|
||||
--health-good: #86efac;
|
||||
--health-warn: #fde68a;
|
||||
--health-bad: #fca5a5;
|
||||
--count-available: #86efac;
|
||||
--count-installed: #93c5fd;
|
||||
--count-retired: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; font-size: 15px; background: var(--bg-body); color: var(--text-body); }
|
||||
a { color: var(--link); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
nav .brand { font-weight: 700; font-size: 1.1rem; margin-right: auto; color: #fff; text-decoration: none; }
|
||||
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; }
|
||||
|
||||
/* 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: var(--flash-success-bg); color: var(--flash-success-text); border: 1px solid var(--flash-success-border); }
|
||||
.flash.error { background: var(--flash-error-bg); color: var(--flash-error-text); border: 1px solid var(--flash-error-border); }
|
||||
.flash.warning { background: var(--flash-warning-bg); color: var(--flash-warning-text); border: 1px solid var(--flash-warning-border); }
|
||||
|
||||
/* Cards / boxes */
|
||||
.card { background: var(--bg-card); border-radius: 6px; box-shadow: var(--shadow-card); 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: var(--bg-th); font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-th); border-bottom: 2px solid var(--border); }
|
||||
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--bg-hover); }
|
||||
|
||||
/* Status badges */
|
||||
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge-available { background: var(--badge-available-bg); color: var(--badge-available-text); }
|
||||
.badge-installed { background: var(--badge-installed-bg); color: var(--badge-installed-text); }
|
||||
.badge-retired { background: var(--badge-retired-bg); color: var(--badge-retired-text); }
|
||||
.badge-warning { background: var(--badge-warning-bg); color: var(--badge-warning-text); }
|
||||
|
||||
/* 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: var(--btn-secondary-bg); color: var(--btn-secondary-text); }
|
||||
.btn-secondary:hover { background: var(--btn-secondary-hover); }
|
||||
.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: var(--text-label); }
|
||||
input[type=text], input[type=number], input[type=date], select, textarea {
|
||||
width: 100%; padding: 0.45rem 0.65rem; border: 1px solid var(--border-input);
|
||||
border-radius: 4px; font-size: 0.9rem; font-family: inherit;
|
||||
background: var(--bg-input); color: var(--text-body);
|
||||
}
|
||||
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: var(--text-h1); }
|
||||
h2 { font-size: 1.15rem; margin-bottom: 0.75rem; color: var(--text-h2); }
|
||||
|
||||
/* Inline form (for POST buttons in tables) */
|
||||
form.inline { display: inline; }
|
||||
|
||||
/* Text utilities */
|
||||
.text-warning { color: var(--text-warning); font-size: 0.8rem; }
|
||||
.text-danger { color: var(--text-danger); }
|
||||
.text-muted { color: var(--text-muted); font-size: 0.85rem; }
|
||||
|
||||
/* Battery health classes */
|
||||
.health-good { color: var(--health-good); }
|
||||
.health-warn { color: var(--health-warn); }
|
||||
.health-bad { color: var(--health-bad); }
|
||||
|
||||
/* ─── Dark mode overrides for inline-styled elements ─────────────── */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
nav { background: #0c1a3b; }
|
||||
nav a { color: #93c5fd; }
|
||||
nav a:hover { background: #1e3a8a; color: #fff; }
|
||||
nav .brand { color: #e2e8f0; }
|
||||
|
||||
#bulk-toolbar { background: var(--bg-toolbar) !important; }
|
||||
#col-picker-panel { background: var(--bg-picker) !important; border-color: var(--border) !important; }
|
||||
#col-picker-panel > div:first-child { color: var(--text-th) !important; }
|
||||
|
||||
#filter-bar select, #filter-bar input,
|
||||
#bulk-toolbar select, #bulk-toolbar input[type=text],
|
||||
#device-filter-bar select, #device-filter-bar input {
|
||||
background: var(--bg-input) !important;
|
||||
color: var(--text-body) !important;
|
||||
border-color: var(--border-input) !important;
|
||||
}
|
||||
.responsive-table td select {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-body);
|
||||
border-color: var(--border-input);
|
||||
}
|
||||
.device-option {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
#install-grid select, #install-grid input {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-body);
|
||||
border-color: var(--border-input);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Mobile responsive ─────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
|
||||
/* Nav: compact on small screens */
|
||||
nav { padding: 0.5rem 0.75rem; gap: 0.35rem; }
|
||||
nav a { font-size: 0.8rem; padding: 0.2rem 0.35rem; }
|
||||
nav .brand { font-size: 1rem; }
|
||||
|
||||
/* Tap targets */
|
||||
.btn { padding: 0.5rem 0.9rem; }
|
||||
.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.825rem; }
|
||||
|
||||
/* Collapse 2-col form grids */
|
||||
.form-grid-2col { grid-template-columns: 1fr !important; }
|
||||
|
||||
/* Card-style table rows */
|
||||
.responsive-table thead { display: none; }
|
||||
.responsive-table tr {
|
||||
display: block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.responsive-table tr:hover td { background: transparent; }
|
||||
.responsive-table td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-bottom: 1px solid var(--bg-th);
|
||||
border-top: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.responsive-table td:last-child { border-bottom: none; }
|
||||
.responsive-table td:empty { display: none; }
|
||||
.responsive-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: var(--text-th);
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Checkbox column: no label prefix, left-aligned */
|
||||
.responsive-table td[data-label=""] {
|
||||
justify-content: flex-start;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
/* Actions column: allow wrapping, override nowrap */
|
||||
.responsive-table td[data-label="Actions"] {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
white-space: normal !important;
|
||||
}
|
||||
.responsive-table td[data-label="Actions"] select {
|
||||
width: 100%;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Filter bar: full-width controls with larger tap targets */
|
||||
#filter-bar select,
|
||||
#filter-bar input[type=text] { flex: 1 1 100%; width: 100% !important; padding: 0.5rem 0.65rem !important; font-size: 0.9rem !important; }
|
||||
|
||||
/* Bulk toolbar: prevent text inputs overflowing */
|
||||
#bulk-toolbar input[type=text] { width: 100% !important; }
|
||||
|
||||
/* Install grid on device_detail */
|
||||
#install-grid { grid-template-columns: 1fr !important; max-width: 100% !important; }
|
||||
|
||||
/* Column picker: keep panel inside viewport */
|
||||
#col-picker-panel { left: 0; right: auto !important; max-width: calc(100vw - 2rem); }
|
||||
|
||||
/* form-actions: stack buttons full-width */
|
||||
.form-actions { flex-direction: column; align-items: stretch; }
|
||||
.form-actions .btn,
|
||||
.form-actions button { width: 100%; text-align: center; }
|
||||
}
|
||||
</style>
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a class="brand" href="{{ url_for('dashboard') }}">Battery Tracker</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>
|
||||
{% set ep = request.endpoint or '' %}
|
||||
<a class="brand" href="{{ url_for('home') }}">Battery Tracker</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">
|
||||
@@ -335,19 +33,6 @@
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer style="text-align:center;padding:1.25rem 1rem 1.5rem;margin-top:1rem;
|
||||
border-top:1px solid var(--border);font-size:0.8rem;">
|
||||
<a href="{{ url_for('export_page') }}"
|
||||
style="color:var(--text-muted);text-decoration:none;"
|
||||
onmouseover="this.style.textDecoration='underline'"
|
||||
onmouseout="this.style.textDecoration='none'">Export data</a>
|
||||
<span style="color:var(--text-muted);margin:0 0.5rem;">·</span>
|
||||
<a href="{{ url_for('import_page') }}"
|
||||
style="color:var(--text-muted);text-decoration:none;"
|
||||
onmouseover="this.style.textDecoration='underline'"
|
||||
onmouseout="this.style.textDecoration='none'">Import data</a>
|
||||
</footer>
|
||||
|
||||
<div id="confirm-modal" role="dialog" aria-modal="true">
|
||||
<div id="confirm-modal-box">
|
||||
<p id="confirm-modal-msg"></p>
|
||||
@@ -358,64 +43,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
}
|
||||
<script src="{{ url_for('static', filename='app.js') }}" defer></script>
|
||||
|
||||
// Inject CSRF token into all POST forms
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '{{ csrf_token() }}';
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
if (form.method.toLowerCase() === 'post') {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
|
||||
form.appendChild(inp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(function() {
|
||||
var modal = document.getElementById('confirm-modal');
|
||||
var msgEl = document.getElementById('confirm-modal-msg');
|
||||
var okBtn = document.getElementById('confirm-modal-ok');
|
||||
var cancelBtn = document.getElementById('confirm-modal-cancel');
|
||||
var _cb = null;
|
||||
|
||||
window.showConfirm = function(msg, onOk, okLabel, okClass) {
|
||||
msgEl.textContent = msg;
|
||||
okBtn.textContent = okLabel || 'Confirm';
|
||||
okBtn.className = 'btn ' + (okClass || 'btn-danger');
|
||||
modal.classList.add('open');
|
||||
_cb = onOk;
|
||||
cancelBtn.focus();
|
||||
};
|
||||
|
||||
function closeModal() { modal.classList.remove('open'); _cb = null; }
|
||||
|
||||
okBtn.addEventListener('click', function() {
|
||||
var cb = _cb; closeModal(); if (cb) cb();
|
||||
});
|
||||
cancelBtn.addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
|
||||
});
|
||||
|
||||
// Global handler: forms with data-confirm attribute
|
||||
document.addEventListener('submit', function(e) {
|
||||
var form = e.target;
|
||||
var msg = form.dataset.confirm;
|
||||
if (!msg || form.dataset.confirmed) return;
|
||||
e.preventDefault();
|
||||
var okLabel = form.dataset.confirmOk || 'Confirm';
|
||||
var okClass = form.dataset.confirmClass || 'btn-danger';
|
||||
window.showConfirm(msg, function() {
|
||||
form.dataset.confirmed = '1';
|
||||
form.submit();
|
||||
}, okLabel, okClass);
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
<nav class="bottom-nav" aria-label="Main navigation">
|
||||
{% set ep = request.endpoint or '' %}
|
||||
<a href="{{ url_for('home') }}" class="bottom-nav-item{% if ep == 'home' %} 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">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<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') %} 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>
|
||||
<span>Devices</span>
|
||||
</a>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+14
-27
@@ -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')">
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">Add Batteries</button>
|
||||
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}">Cancel</a>
|
||||
<a class="btn btn-secondary" href="{{ url_for('battery_list') }}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -105,19 +105,6 @@
|
||||
<script>
|
||||
var prefixMaxNums = {{ prefix_max_nums|default({})|tojson }};
|
||||
|
||||
function metaSelectChanged(sel, inputId) {
|
||||
var input = document.getElementById(inputId);
|
||||
if (sel.value === '__new__') {
|
||||
input.style.display = '';
|
||||
input.value = '';
|
||||
input.focus();
|
||||
} else {
|
||||
input.style.display = 'none';
|
||||
input.value = sel.value;
|
||||
}
|
||||
if (inputId === 'brand' || inputId === 'size') updateLabelPreview();
|
||||
}
|
||||
|
||||
function updateLabelPreview() {
|
||||
var brand = document.getElementById('brand').value.trim();
|
||||
var size = document.getElementById('size').value.trim();
|
||||
|
||||
@@ -362,7 +362,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="text-muted" href="{{ url_for('dashboard') }}">← Back to Dashboard</a>
|
||||
<a class="text-muted" href="{{ url_for('battery_list') }}">← Back to Batteries</a>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
@@ -427,18 +427,6 @@
|
||||
});
|
||||
}());
|
||||
|
||||
function metaSelectChanged(sel, inputId) {
|
||||
var input = document.getElementById(inputId);
|
||||
if (sel.value === '__new__') {
|
||||
input.style.display = '';
|
||||
input.value = '';
|
||||
input.focus();
|
||||
} else {
|
||||
input.style.display = 'none';
|
||||
input.value = sel.value;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Percentage mini chart ─────────────────────────────────────────────────
|
||||
(function() {
|
||||
var canvas = document.getElementById('pct-chart');
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Batteries — Battery Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Batteries</h1>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
|
||||
<a class="btn btn-primary btn-sm" href="{{ url_for('battery_add') }}">+ Add Battery</a>
|
||||
<div style="position:relative;">
|
||||
<button type="button" id="col-picker-btn" class="btn btn-sm btn-secondary">Columns ▾</button>
|
||||
<div id="col-picker-panel" style="display:none;position:absolute;right:0;top:calc(100% + 4px);background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:0.75rem 1rem;z-index:100;box-shadow:0 4px 8px rgba(0,0,0,0.1);min-width:180px;">
|
||||
<div style="font-weight:600;font-size:0.8rem;color:#64748b;margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em;">Show columns</div>
|
||||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="last-charged" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Last Charged</label>
|
||||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="health" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Health %</label>
|
||||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="chemistry" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Chemistry</label>
|
||||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="capacity" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Capacity</label>
|
||||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="storage" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Storage Location</label>
|
||||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="purchase" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Purchase Date</label>
|
||||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="cycles" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Charge Cycles</label>
|
||||
{% if ha_enabled %}<label style="display:block;cursor:pointer;font-size:0.875rem;"><input type="checkbox" data-col="ha-pct" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Battery %</label>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="filter-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;">
|
||||
<select id="filter-status" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
<option value="active" selected>Active (non-retired)</option>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="available">Available</option>
|
||||
<option value="installed">Installed</option>
|
||||
<option value="retired">Retired</option>
|
||||
</select>
|
||||
<select id="filter-brand" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
<option value="">All Brands</option>
|
||||
{% for b in batteries|map(attribute='brand')|unique|sort %}
|
||||
<option value="{{ b }}">{{ b }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="filter-size" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
<option value="">All Sizes</option>
|
||||
{% for s in batteries|map(attribute='size')|select|unique|sort %}
|
||||
<option value="{{ s }}">{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="filter-storage" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
<option value="">All Locations</option>
|
||||
{% for loc in storage_locations %}
|
||||
<option value="{{ loc }}">{{ loc }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" id="filter-text" oninput="applyFilters()" placeholder="Search…"
|
||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:140px;">
|
||||
<input type="number" id="filter-range-from" oninput="applyFilters()" placeholder="# from"
|
||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:70px;">
|
||||
<input type="number" id="filter-range-to" oninput="applyFilters()" placeholder="# to"
|
||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:70px;">
|
||||
<button type="button" id="select-all-btn" onclick="mobileSelectAll()" class="btn btn-sm btn-secondary" style="display:none;">Select all</button>
|
||||
<button type="button" onclick="resetFilters()" class="btn btn-sm btn-secondary" id="filter-reset" style="display:none;">✕ Reset</button>
|
||||
<span id="filter-count" style="font-size:0.8rem;color:#64748b;"></span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('battery_bulk_action') }}" id="bulk-form">
|
||||
|
||||
<div id="bulk-toolbar" style="display:none;margin-bottom:0.75rem;padding:0.6rem 0.75rem;background:#f1f5f9;border-radius:6px;align-items:center;gap:0.5rem;flex-wrap:wrap;position:sticky;top:0;z-index:90;box-shadow:0 2px 6px rgba(0,0,0,.08);">
|
||||
<span id="selected-count" style="font-size:0.85rem;color:#64748b;margin-right:0.25rem;"></span>
|
||||
<button class="btn btn-sm btn-warning" name="action" value="unassign" type="submit">Unassign</button>
|
||||
<button class="btn btn-sm btn-secondary" name="action" value="retire" type="submit">Retire</button>
|
||||
<button class="btn btn-sm btn-danger" name="action" value="delete" type="button"
|
||||
onclick="bulkActionConfirm(this, 'Permanently delete selected batteries?', 'Delete', 'btn-danger')">Delete</button>
|
||||
<span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;">
|
||||
<input type="hidden" name="field_name" id="bulk-field-name" value="storage_location">
|
||||
<select id="bulk-field-select" onchange="updateBulkField(this)"
|
||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
<option value="storage_location">Storage Location</option>
|
||||
<option value="brand">Brand</option>
|
||||
</select>
|
||||
<!-- Storage Location value -->
|
||||
<span id="bulk-val-storage_location" style="display:flex;gap:0.25rem;align-items:center;">
|
||||
<select id="bulk-storage-select" onchange="bulkStorageChanged(this)"
|
||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
<option value="">— select —</option>
|
||||
{% for loc in storage_locations|default([]) %}
|
||||
<option value="{{ loc }}">{{ loc }}</option>
|
||||
{% endfor %}
|
||||
<option value="__new__">➕ New location…</option>
|
||||
</select>
|
||||
<input type="text" id="bulk-storage-text"
|
||||
style="display:none;padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:140px;"
|
||||
placeholder="Type location">
|
||||
<input type="hidden" name="field_value" id="bulk-field-value-storage" value="">
|
||||
</span>
|
||||
<!-- Brand value -->
|
||||
<span id="bulk-val-brand" style="display:none;">
|
||||
<input type="text" id="bulk-brand-text" oninput="document.getElementById('bulk-field-value-brand').value=this.value"
|
||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:160px;"
|
||||
placeholder="New brand name">
|
||||
<input type="hidden" name="field_value" id="bulk-field-value-brand" value="">
|
||||
</span>
|
||||
<button class="btn btn-sm btn-primary" name="action" value="set_field" type="submit">Apply</button>
|
||||
</span>
|
||||
<span style="display:flex;gap:0.35rem;align-items:center;">
|
||||
<select id="bulk-device-select" name="device_id"
|
||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
<option value="">— select device —</option>
|
||||
{% for d in devices_with_slots %}
|
||||
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary" name="action" value="install_device" type="button"
|
||||
onclick="confirmInstallDevice(this)">Install in device</button>
|
||||
</span>
|
||||
<span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;">
|
||||
<input type="date" name="charged_date" id="bulk-charged-date" value="{{ today.isoformat() }}"
|
||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
<label style="font-size:0.85rem;display:flex;align-items:center;gap:0.25rem;cursor:pointer;">
|
||||
<input type="checkbox" name="increment_cycles" id="bulk-increment-cycles" value="1" checked>
|
||||
+cycle
|
||||
</label>
|
||||
<button class="btn btn-sm btn-primary" name="action" value="log_charged" type="submit"
|
||||
onclick="return validateBulkCharge()">Log Charged</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="responsive-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:1.5rem;"><input type="checkbox" id="select-all" title="Select all"></th>
|
||||
<th data-sortable="label">Label</th>
|
||||
<th data-sortable="brand">Brand</th>
|
||||
<th>Size</th>
|
||||
<th class="col-last-charged" style="display:none;" data-sortable="last-charged">Last Charged</th>
|
||||
<th class="col-health" style="display:none;" data-sortable="health">Health</th>
|
||||
<th class="col-chemistry" style="display:none;">Chemistry</th>
|
||||
<th class="col-capacity" style="display:none;">Capacity</th>
|
||||
<th class="col-storage" style="display:none;">Storage</th>
|
||||
<th class="col-purchase" style="display:none;">Purchase Date</th>
|
||||
<th class="col-cycles" style="display:none;" data-sortable="cycles">Cycles</th>
|
||||
{% if ha_enabled %}<th class="col-ha-pct" style="display:none;" data-sortable="ha-pct">Bat %</th>{% endif %}
|
||||
<th data-sortable="status">Status</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in batteries %}
|
||||
<tr data-brand="{{ b.brand }}" data-size="{{ b.size or '' }}" data-status="{{ b.status }}" data-storage="{{ b.storage_location or '' }}" data-label="{{ b.label }}">
|
||||
<td data-label=""><input type="checkbox" name="battery_ids" value="{{ b.id }}" class="row-cb"></td>
|
||||
<td data-label="Label" data-sort-col="label" data-sort="{{ b.label }}"><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
|
||||
<td data-label="Brand" data-sort-col="brand" data-sort="{{ b.brand }}">{{ b.brand }}</td>
|
||||
<td data-label="Size">{{ b.size or '—' }}</td>
|
||||
<td data-label="Last Charged" class="col-last-charged" style="display:none;"
|
||||
data-sort-col="last-charged" data-sort="{{ last_charged_map.get(b.id, '') }}"
|
||||
data-charged="{{ last_charged_map.get(b.id, '') }}"></td>
|
||||
<td data-label="Health" class="col-health" style="display:none;"
|
||||
data-sort-col="health" data-sort="{% if b.tested_capacity_mah and b.capacity_mah %}{{ (b.tested_capacity_mah / b.capacity_mah * 100)|int }}{% endif %}">
|
||||
{% if b.tested_capacity_mah and b.capacity_mah %}
|
||||
{% set hp = (b.tested_capacity_mah / b.capacity_mah * 100)|int %}
|
||||
<span class="badge {% if hp >= 80 %}badge-available{% elif hp >= 60 %}badge-warning{% else %}badge-retired{% endif %}">{{ hp }}%</span>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td data-label="Chemistry" class="col-chemistry" style="display:none;">{{ b.chemistry or '—' }}</td>
|
||||
<td data-label="Capacity" class="col-capacity" style="display:none;">
|
||||
{% if b.capacity_mah %}
|
||||
{% if b.tested_capacity_mah %}{{ b.tested_capacity_mah }}/{{ b.capacity_mah }} mAh
|
||||
{% else %}{{ b.capacity_mah }} mAh{% endif %}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td data-label="Storage" class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td>
|
||||
<td data-label="Purchase" class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td>
|
||||
<td data-label="Cycles" class="col-cycles" style="display:none;"
|
||||
data-sort-col="cycles" data-sort="{{ b.charge_cycles or '' }}">{{ b.charge_cycles or '—' }}</td>
|
||||
{% if ha_enabled %}
|
||||
<td data-label="Bat %" class="col-ha-pct" style="display:none;"
|
||||
data-sort-col="ha-pct" data-sort="{{ b.battery_percentage if b.battery_percentage is not none else '' }}">
|
||||
{% if b.battery_percentage is not none %}
|
||||
{% if b.battery_percentage < 20 and b.status != 'retired' %}
|
||||
<span class="badge badge-warning" title="Low — consider replacing">⚠ {{ b.battery_percentage }}%</span>
|
||||
{% else %}{{ b.battery_percentage }}%{% endif %}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td data-label="Status" data-sort-col="status" data-sort="{{ b.status }}">
|
||||
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
|
||||
</td>
|
||||
<td data-label="Assigned To">
|
||||
{% if b.device %}
|
||||
{% if b.device.parent %}
|
||||
<a href="{{ url_for('device_detail', device_id=b.device.parent.id) }}">{{ b.device.parent.name }}</a>
|
||||
<span class="text-muted"> / </span>
|
||||
{% endif %}
|
||||
<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 data-label="Actions" 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() %}
|
||||
<select id="qas-{{ b.id }}"
|
||||
style="padding:0.2rem 0.3rem;font-size:0.8rem;border:1px solid #cbd5e1;border-radius:4px;max-width:110px;vertical-align:middle;">
|
||||
<option value="">— assign —</option>
|
||||
{% for d in devices_with_slots %}
|
||||
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-primary"
|
||||
onclick="quickAssign('{{ url_for('battery_assign', battery_id=b.id) }}', {{ b.id }})">→</button>
|
||||
{% endif %}
|
||||
|
||||
{% if b.is_installed() %}
|
||||
<button class="btn btn-sm btn-warning" type="submit"
|
||||
formaction="{{ url_for('battery_unassign', battery_id=b.id) }}">Unassign</button>
|
||||
{% endif %}
|
||||
|
||||
{% if not b.is_retired() %}
|
||||
<button class="btn btn-sm btn-secondary" type="submit"
|
||||
formaction="{{ url_for('battery_retire', battery_id=b.id) }}">Retire</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="12" class="text-muted" style="text-align:center;padding:1rem;">No batteries found. <a href="{{ url_for('battery_add') }}">Add some.</a></td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pagination-bar" style="display:flex;align-items:center;gap:0.75rem;margin-top:0.75rem;flex-wrap:wrap;">
|
||||
<button id="prev-page" class="btn btn-sm btn-secondary" type="button"
|
||||
onclick="currentPage--; applyPagination();" disabled>← Prev</button>
|
||||
<span id="page-info" style="color:var(--text-muted);font-size:0.9rem;"></span>
|
||||
<button id="next-page" class="btn btn-sm btn-secondary" type="button"
|
||||
onclick="currentPage++; applyPagination();" disabled>Next →</button>
|
||||
<select id="page-size-select" onchange="PAGE_SIZE=+this.value||Infinity;currentPage=1;applyPagination();"
|
||||
style="padding:0.2rem 0.4rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
<option value="10">10 / page</option>
|
||||
<option value="25" selected>25 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
<option value="">All</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
var selectAll = document.getElementById('select-all');
|
||||
var toolbar = document.getElementById('bulk-toolbar');
|
||||
var countEl = document.getElementById('selected-count');
|
||||
var selectAllBtn = document.getElementById('select-all-btn');
|
||||
var PAGE_SIZE = 25;
|
||||
var currentPage = 1;
|
||||
|
||||
function visibleCbs() {
|
||||
return Array.prototype.filter.call(
|
||||
document.querySelectorAll('.row-cb'),
|
||||
function(cb) { return cb.closest('tr').style.display !== 'none'; }
|
||||
);
|
||||
}
|
||||
|
||||
function updateToolbar() {
|
||||
var checked = document.querySelectorAll('.row-cb:checked');
|
||||
var vis = visibleCbs();
|
||||
var n = checked.length;
|
||||
toolbar.style.display = n > 0 ? 'flex' : 'none';
|
||||
countEl.textContent = n + ' selected';
|
||||
var visChecked = vis.filter(function(cb) { return cb.checked; });
|
||||
selectAll.indeterminate = visChecked.length > 0 && visChecked.length < vis.length;
|
||||
selectAll.checked = vis.length > 0 && visChecked.length === vis.length;
|
||||
if (selectAllBtn) {
|
||||
selectAllBtn.style.display = vis.length > 0 ? '' : 'none';
|
||||
selectAllBtn.textContent = (vis.length > 0 && visChecked.length === vis.length)
|
||||
? 'Deselect all' : 'Select all';
|
||||
}
|
||||
}
|
||||
|
||||
function mobileSelectAll() {
|
||||
var vis = visibleCbs();
|
||||
var allChecked = vis.length > 0 && vis.every(function(cb) { return cb.checked; });
|
||||
vis.forEach(function(cb) { cb.checked = !allChecked; });
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.row-cb').forEach(function(cb) {
|
||||
cb.addEventListener('change', updateToolbar);
|
||||
});
|
||||
selectAll.addEventListener('change', function() {
|
||||
visibleCbs().forEach(function(cb) { cb.checked = selectAll.checked; });
|
||||
updateToolbar();
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
var status = document.getElementById('filter-status').value;
|
||||
var brand = document.getElementById('filter-brand').value;
|
||||
var size = document.getElementById('filter-size').value;
|
||||
var storage = document.getElementById('filter-storage').value;
|
||||
var text = document.getElementById('filter-text').value.trim().toLowerCase();
|
||||
var rangeFromVal = document.getElementById('filter-range-from').value.trim();
|
||||
var rangeToVal = document.getElementById('filter-range-to').value.trim();
|
||||
var rangeMin = rangeFromVal !== '' ? parseInt(rangeFromVal, 10) : null;
|
||||
var rangeMax = rangeToVal !== '' ? parseInt(rangeToVal, 10) : null;
|
||||
var anyActive = (status && status !== 'active') || brand || size || storage || text || rangeFromVal || rangeToVal;
|
||||
document.getElementById('filter-reset').style.display = anyActive ? '' : 'none';
|
||||
|
||||
var rows = document.querySelectorAll('tbody tr[data-brand]');
|
||||
var filtered = 0;
|
||||
rows.forEach(function(row) {
|
||||
var show = true;
|
||||
if (status === 'active') {
|
||||
if (row.dataset.status === 'retired') show = false;
|
||||
} else if (status) {
|
||||
if (row.dataset.status !== status) show = false;
|
||||
}
|
||||
if (brand && row.dataset.brand !== brand) show = false;
|
||||
if (size && row.dataset.size !== size) show = false;
|
||||
if (storage && row.dataset.storage !== storage) show = false;
|
||||
if (text && row.textContent.toLowerCase().indexOf(text) === -1) show = false;
|
||||
if (rangeMin !== null || rangeMax !== null) {
|
||||
var lbl = row.dataset.label || '';
|
||||
var m = lbl.match(/(\d+)\s*$/);
|
||||
var n = m ? parseInt(m[1], 10) : null;
|
||||
if (n === null) show = false;
|
||||
else if (rangeMin !== null && n < rangeMin) show = false;
|
||||
else if (rangeMax !== null && n > rangeMax) show = false;
|
||||
}
|
||||
row.dataset.filteredOut = show ? '' : '1';
|
||||
if (show) filtered++;
|
||||
});
|
||||
|
||||
var fc = document.getElementById('filter-count');
|
||||
fc.textContent = anyActive ? (filtered + ' of ' + rows.length + ' shown') : '';
|
||||
currentPage = 1;
|
||||
applyPagination();
|
||||
}
|
||||
|
||||
function applyPagination() {
|
||||
var allRows = Array.from(document.querySelectorAll('tbody tr[data-brand]'));
|
||||
var visible = allRows.filter(function(r) { return !r.dataset.filteredOut; });
|
||||
var totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE));
|
||||
if (currentPage > totalPages) currentPage = totalPages;
|
||||
var start = (currentPage - 1) * PAGE_SIZE;
|
||||
|
||||
allRows.forEach(function(r) { r.style.display = 'none'; });
|
||||
visible.slice(start, start + PAGE_SIZE).forEach(function(r) { r.style.display = ''; });
|
||||
|
||||
var info = document.getElementById('page-info');
|
||||
var prev = document.getElementById('prev-page');
|
||||
var next = document.getElementById('next-page');
|
||||
if (info) info.textContent = visible.length > PAGE_SIZE
|
||||
? 'Page ' + currentPage + ' of ' + totalPages : '';
|
||||
if (prev) prev.disabled = currentPage <= 1;
|
||||
if (next) next.disabled = currentPage >= totalPages;
|
||||
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
function confirmInstallDevice(btn) {
|
||||
var deviceSel = document.getElementById('bulk-device-select');
|
||||
if (!deviceSel.value) { deviceSel.focus(); return; }
|
||||
var movers = Array.prototype.filter.call(
|
||||
document.querySelectorAll('.row-cb:checked'),
|
||||
function(cb) { return cb.closest('tr').dataset.status === 'installed'; }
|
||||
);
|
||||
if (movers.length > 0) {
|
||||
var n = movers.length;
|
||||
showConfirm(
|
||||
n + ' selected batter' + (n === 1 ? 'y is' : 'ies are') +
|
||||
' already installed elsewhere. Unassign and move to the selected device?',
|
||||
function() { submitWithAction(btn); },
|
||||
'Move', 'btn-warning'
|
||||
);
|
||||
} else {
|
||||
submitWithAction(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function bulkActionConfirm(btn, msg, okLabel, okClass) {
|
||||
showConfirm(msg, function() { submitWithAction(btn); }, okLabel, okClass);
|
||||
}
|
||||
|
||||
function submitWithAction(btn) {
|
||||
var form = btn.form || document.getElementById('bulk-form');
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = btn.name; inp.value = btn.value;
|
||||
form.appendChild(inp);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function quickAssign(action, batteryId) {
|
||||
var sel = document.getElementById('qas-' + batteryId);
|
||||
if (!sel.value) { sel.focus(); return; }
|
||||
var f = document.createElement('form');
|
||||
f.method = 'post'; f.action = action;
|
||||
var deviceInp = document.createElement('input');
|
||||
deviceInp.type = 'hidden'; deviceInp.name = 'device_id'; deviceInp.value = sel.value;
|
||||
f.appendChild(deviceInp);
|
||||
var csrfInp = document.createElement('input');
|
||||
csrfInp.type = 'hidden'; csrfInp.name = 'csrf_token'; csrfInp.value = CSRF_TOKEN;
|
||||
f.appendChild(csrfInp);
|
||||
document.body.appendChild(f);
|
||||
f.submit();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('filter-status').value = 'active';
|
||||
['filter-brand','filter-size','filter-storage'].forEach(function(id) {
|
||||
document.getElementById(id).value = '';
|
||||
});
|
||||
document.getElementById('filter-text').value = '';
|
||||
document.getElementById('filter-range-from').value = '';
|
||||
document.getElementById('filter-range-to').value = '';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function updateBulkField(sel) {
|
||||
var field = sel.value;
|
||||
document.getElementById('bulk-field-name').value = field;
|
||||
document.getElementById('bulk-val-storage_location').style.display = field === 'storage_location' ? 'flex' : 'none';
|
||||
document.getElementById('bulk-val-brand').style.display = field === 'brand' ? 'flex' : 'none';
|
||||
document.getElementById('bulk-field-value-storage').disabled = (field !== 'storage_location');
|
||||
document.getElementById('bulk-field-value-brand').disabled = (field !== 'brand');
|
||||
}
|
||||
// initialise disabled state on page load
|
||||
document.getElementById('bulk-field-value-brand').disabled = true;
|
||||
updateToolbar();
|
||||
|
||||
// Column picker
|
||||
var COL_KEY = 'battery_cols';
|
||||
var ALL_COLS = ['last-charged','health','chemistry','capacity','storage','purchase','cycles'{% if ha_enabled %},'ha-pct'{% endif %}];
|
||||
|
||||
function toggleCol(cb) {
|
||||
var col = cb.dataset.col;
|
||||
document.querySelectorAll('.col-' + col).forEach(function(el) {
|
||||
el.style.display = cb.checked ? '' : 'none';
|
||||
});
|
||||
var prefs = JSON.parse(localStorage.getItem(COL_KEY) || '{}');
|
||||
prefs[col] = cb.checked;
|
||||
localStorage.setItem(COL_KEY, JSON.stringify(prefs));
|
||||
}
|
||||
|
||||
(function loadColPrefs() {
|
||||
var prefs = JSON.parse(localStorage.getItem(COL_KEY) || '{}');
|
||||
ALL_COLS.forEach(function(col) {
|
||||
if (prefs[col]) {
|
||||
var cb = document.querySelector('[data-col="' + col + '"]');
|
||||
if (cb) { cb.checked = true; toggleCol(cb); }
|
||||
}
|
||||
});
|
||||
}());
|
||||
|
||||
// Relative age for "Last Charged" column
|
||||
function relAge(dateStr) {
|
||||
if (!dateStr) return {text: 'Never', cls: 'text-danger'};
|
||||
var days = Math.floor((Date.now() - new Date(dateStr + 'T00:00:00')) / 86400000);
|
||||
if (days <= 0) return {text: 'Today', cls: ''};
|
||||
if (days === 1) return {text: 'Yesterday', cls: ''};
|
||||
if (days < 14) return {text: days + ' days ago', cls: ''};
|
||||
if (days < 60) return {text: Math.floor(days / 7) + ' wks ago', cls: days > 30 ? 'text-warning' : ''};
|
||||
if (days < 365) return {text: Math.floor(days / 30) + ' mo ago', cls: days > 180 ? 'text-danger' : 'text-warning'};
|
||||
return {text: Math.floor(days / 365) + ' yr ago', cls: 'text-danger'};
|
||||
}
|
||||
document.querySelectorAll('td[data-charged]').forEach(function(td) {
|
||||
var r = relAge(td.dataset.charged);
|
||||
td.innerHTML = '<span class="' + r.cls + '">' + r.text + '</span>';
|
||||
});
|
||||
|
||||
// Sortable columns
|
||||
var _sortCol = null, _sortDir = 1;
|
||||
var _origOrder = null;
|
||||
|
||||
function _captureOrder() {
|
||||
if (!_origOrder) {
|
||||
_origOrder = Array.from(document.querySelectorAll('tbody tr[data-brand]'));
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('th[data-sortable]').forEach(function(th) {
|
||||
th.style.cursor = 'pointer';
|
||||
th.style.userSelect = 'none';
|
||||
var ind = document.createElement('span');
|
||||
ind.className = 'sort-ind';
|
||||
ind.style.fontSize = '0.7rem';
|
||||
th.appendChild(ind);
|
||||
th.addEventListener('click', function() {
|
||||
var col = th.dataset.sortable;
|
||||
if (_sortCol === col) {
|
||||
_sortDir = _sortDir === 1 ? -1 : 0;
|
||||
} else {
|
||||
_sortCol = col; _sortDir = 1;
|
||||
}
|
||||
document.querySelectorAll('th[data-sortable] .sort-ind').forEach(function(s) { s.textContent = ''; });
|
||||
if (_sortDir === 0) {
|
||||
_sortCol = null; _sortDir = 1;
|
||||
_captureOrder();
|
||||
var tbody = document.querySelector('tbody');
|
||||
_origOrder.forEach(function(r) { tbody.appendChild(r); });
|
||||
applyPagination();
|
||||
return;
|
||||
}
|
||||
ind.textContent = _sortDir === 1 ? ' ▲' : ' ▼';
|
||||
_captureOrder();
|
||||
var tbody = document.querySelector('tbody');
|
||||
var rows = Array.from(tbody.querySelectorAll('tr[data-brand]'));
|
||||
rows.sort(function(a, b) {
|
||||
var at = a.querySelector('td[data-sort-col="' + col + '"]');
|
||||
var bt = b.querySelector('td[data-sort-col="' + col + '"]');
|
||||
var av = at ? (at.dataset.sort || '') : '';
|
||||
var bv = bt ? (bt.dataset.sort || '') : '';
|
||||
// empty values always sort last
|
||||
if (!av && !bv) return 0;
|
||||
if (!av) return 1;
|
||||
if (!bv) return -1;
|
||||
var an = parseFloat(av), bn = parseFloat(bv);
|
||||
if (!isNaN(an) && !isNaN(bn)) return (an - bn) * _sortDir;
|
||||
return av.localeCompare(bv) * _sortDir;
|
||||
});
|
||||
rows.forEach(function(r) { tbody.appendChild(r); });
|
||||
applyPagination();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('col-picker-btn').addEventListener('click', function(e) {
|
||||
var panel = document.getElementById('col-picker-panel');
|
||||
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||||
e.stopPropagation();
|
||||
});
|
||||
document.addEventListener('click', function() {
|
||||
document.getElementById('col-picker-panel').style.display = 'none';
|
||||
});
|
||||
|
||||
function validateBulkCharge() {
|
||||
var d = document.getElementById('bulk-charged-date');
|
||||
if (!d.value) { d.focus(); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function bulkStorageChanged(sel) {
|
||||
var text = document.getElementById('bulk-storage-text');
|
||||
var hidden = document.getElementById('bulk-field-value-storage');
|
||||
if (sel.value === '__new__') {
|
||||
text.style.display = '';
|
||||
text.value = '';
|
||||
text.oninput = function() { hidden.value = text.value; };
|
||||
text.focus();
|
||||
hidden.value = '';
|
||||
} else {
|
||||
text.style.display = 'none';
|
||||
hidden.value = sel.value;
|
||||
}
|
||||
}
|
||||
|
||||
applyFilters();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -56,6 +56,7 @@
|
||||
</div>
|
||||
</div><!-- #battery-fields -->
|
||||
|
||||
{% if not prefill_parent %}
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
|
||||
@@ -94,6 +95,7 @@
|
||||
placeholder="e.g. Living Room, Bedroom"
|
||||
style="display:none;margin-top:0.4rem;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
@@ -127,17 +129,5 @@ function toggleSubcomponents(cb) {
|
||||
size.setAttribute('required', '');
|
||||
}
|
||||
}
|
||||
|
||||
function metaSelectChanged(sel, inputId) {
|
||||
var input = document.getElementById(inputId);
|
||||
if (sel.value === '__new__') {
|
||||
input.style.display = '';
|
||||
input.value = '';
|
||||
input.focus();
|
||||
} else {
|
||||
input.style.display = 'none';
|
||||
input.value = sel.value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+145
-22
@@ -63,6 +63,20 @@
|
||||
<td style="border:none;">{{ device.location }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if device.is_subcomponent() and device.parent %}
|
||||
{% if device.parent.device_type %}
|
||||
<tr>
|
||||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Type</td>
|
||||
<td style="border:none;">{{ device.parent.device_type }} <span class="text-muted" style="font-size:0.85em;">(inherited)</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if device.parent.location %}
|
||||
<tr>
|
||||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Location</td>
|
||||
<td style="border:none;">{{ device.parent.location }} <span class="text-muted" style="font-size:0.85em;">(inherited)</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if device.notes %}
|
||||
<tr>
|
||||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
|
||||
@@ -94,17 +108,85 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if device.children or not device.is_subcomponent() %}
|
||||
{% if device.children or device.can_have_subcomponents() %}
|
||||
<div class="card">
|
||||
{% if device.children %}
|
||||
<h2>Sub-components</h2>
|
||||
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.75rem;">
|
||||
<div style="display:flex;flex-direction:column;gap:0.5rem;margin-bottom:0.75rem;">
|
||||
{% for child in device.children %}
|
||||
<a href="{{ url_for('device_detail', device_id=child.id) }}"
|
||||
style="display:block;padding:0.6rem 0.9rem;border:1px solid var(--border);border-radius:6px;text-decoration:none;min-width:140px;background:var(--bg-card);">
|
||||
<strong>{{ child.name }}</strong><br>
|
||||
<small class="text-muted">{{ child.installed_count() }}/{{ child.battery_slots }} slots · {{ child.battery_size }}{% if child.ha_entity_id %} · HA{% endif %}</small>
|
||||
</a>
|
||||
{% set child_free = child.battery_slots - child.installed_count() %}
|
||||
{% set child_batteries = children_avail.get(child.id, []) %}
|
||||
<details style="border:1px solid var(--border);border-radius:6px;background:var(--bg-card);">
|
||||
<summary style="padding:0.6rem 0.9rem;cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;">
|
||||
<span>
|
||||
<strong>{{ child.name }}</strong>
|
||||
<small class="text-muted" style="margin-left:0.5rem;">{{ child.installed_count() }}/{{ child.battery_slots }} slots · {{ child.battery_size }}{% if child.ha_entity_id %} · HA{% endif %}</small>
|
||||
</span>
|
||||
<span style="display:flex;gap:0.5rem;align-items:center;">
|
||||
{% if child_free > 0 and child_batteries %}<small class="text-muted">{{ child_batteries|length }} available</small>{% endif %}
|
||||
<a href="{{ url_for('device_detail', device_id=child.id) }}" onclick="event.stopPropagation();" style="font-size:0.8rem;">View</a>
|
||||
</span>
|
||||
</summary>
|
||||
<div style="padding:0.6rem 0.9rem;border-top:1px solid var(--border);">
|
||||
{% if child_free <= 0 %}
|
||||
<p class="text-muted" style="margin:0;">No free slots.</p>
|
||||
{% elif not child_batteries %}
|
||||
<p class="text-muted" style="margin:0;">No compatible batteries available.</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('device_install_batch', device_id=child.id) }}"
|
||||
data-confirm="Install the selected batteries into {{ child.name }}?"
|
||||
data-confirm-ok="Install" data-confirm-class="btn-primary">
|
||||
<div style="margin-bottom:0.4rem;">
|
||||
<label style="font-size:0.85rem;cursor:pointer;">
|
||||
<input type="checkbox" id="select-all-child-{{ child.id }}" onchange="toggleAllChild{{ child.id }}(this)"> Select all
|
||||
</label>
|
||||
</div>
|
||||
<div style="max-height:180px;overflow-y:auto;border:1px solid #cbd5e1;border-radius:4px;padding:0.4rem 0.6rem;">
|
||||
{% for b in child_batteries %}
|
||||
<div>
|
||||
<label style="font-size:0.9rem;cursor:pointer;">
|
||||
<input type="checkbox" class="child-bat-{{ child.id }}" name="battery_ids" value="{{ b.id }}">
|
||||
{{ b.label }} — {{ b.brand }}{% if b.battery_percentage is not none %} ({{ b.battery_percentage }}%){% endif %}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" type="submit" style="margin-top:0.5rem;">Install Selected</button>
|
||||
</form>
|
||||
<script>
|
||||
(function() {
|
||||
var FREE_{{ child.id }} = {{ child_free }};
|
||||
var queue_{{ child.id }} = [];
|
||||
document.querySelectorAll('.child-bat-{{ child.id }}').forEach(function(cb) {
|
||||
cb.addEventListener('change', function() {
|
||||
if (cb.checked) {
|
||||
queue_{{ child.id }}.push(cb);
|
||||
if (queue_{{ child.id }}.length > FREE_{{ child.id }}) {
|
||||
queue_{{ child.id }}.shift().checked = false;
|
||||
}
|
||||
} else {
|
||||
queue_{{ child.id }} = queue_{{ child.id }}.filter(function(c) { return c !== cb; });
|
||||
}
|
||||
document.getElementById('select-all-child-{{ child.id }}').checked = false;
|
||||
});
|
||||
});
|
||||
window['toggleAllChild{{ child.id }}'] = function(masterCb) {
|
||||
var all = Array.from(document.querySelectorAll('.child-bat-{{ child.id }}'));
|
||||
if (masterCb.checked) {
|
||||
queue_{{ child.id }} = [];
|
||||
all.forEach(function(c) { c.checked = false; });
|
||||
all.slice(0, FREE_{{ child.id }}).forEach(function(c) { c.checked = true; queue_{{ child.id }}.push(c); });
|
||||
if (all.length > FREE_{{ child.id }}) masterCb.checked = false;
|
||||
} else {
|
||||
all.forEach(function(c) { c.checked = false; });
|
||||
queue_{{ child.id }} = [];
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -116,24 +198,30 @@
|
||||
{% endif %}
|
||||
|
||||
{% if device.has_children() %}
|
||||
<div class="card">
|
||||
<h2>Batteries in Sub-components</h2>
|
||||
{% for child in device.children %}
|
||||
{% set child_installed = child.batteries | selectattr('status', 'eq', 'installed') | list %}
|
||||
<h3 style="margin:0.75rem 0 0.4rem;font-size:1rem;">
|
||||
<a href="{{ url_for('device_detail', device_id=child.id) }}">{{ child.name }}</a>
|
||||
<small class="text-muted" style="font-weight:normal;"> {{ child_installed|length }}/{{ child.battery_slots }}</small>
|
||||
</h3>
|
||||
{% if child_installed %}
|
||||
<div class="card" id="installed">
|
||||
<h2>Installed Batteries</h2>
|
||||
{% if flat_installed %}
|
||||
<div class="table-wrap">
|
||||
<table class="responsive-table">
|
||||
<thead><tr><th>Label</th><th>Brand</th>{% if ha_enabled %}<th>Bat %</th>{% endif %}<th>Last Charged</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th>Component</th><th>Label</th><th>Brand</th>
|
||||
{% if ha_enabled %}<th>Bat %</th>{% endif %}
|
||||
<th>Last Charged</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for b in child_installed %}
|
||||
{% for b, comp_name, comp_id in flat_installed %}
|
||||
<tr>
|
||||
<td data-label="Component"><a href="{{ url_for('device_detail', device_id=comp_id) }}">{{ comp_name }}</a></td>
|
||||
<td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
|
||||
<td data-label="Brand">{{ b.brand }}</td>
|
||||
{% if ha_enabled %}<td data-label="Bat %">{% if b.battery_percentage is not none %}{{ b.battery_percentage }}%{% else %}—{% endif %}</td>{% endif %}
|
||||
{% if ha_enabled %}
|
||||
<td data-label="Bat %">
|
||||
{% if b.battery_percentage is not none %}
|
||||
{% if b.battery_percentage < 20 %}<span class="badge badge-warning">⚠ {{ b.battery_percentage }}%</span>
|
||||
{% else %}{{ b.battery_percentage }}%{% endif %}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td data-label="Last Charged" class="text-muted">{{ b.charge_logs[-1].charged_date if b.charge_logs else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -141,9 +229,36 @@
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted" style="margin:0 0 0.5rem;">No batteries installed.</p>
|
||||
<p class="text-muted">No batteries installed across sub-components.</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if device.has_children() and flat_installed %}
|
||||
<div class="card">
|
||||
<h2>Charge All Installed Batteries</h2>
|
||||
<form method="post" action="{{ url_for('device_charge_all', device_id=device.id) }}"
|
||||
data-confirm="Log a charge for all installed batteries in {{ device.name }}?"
|
||||
data-confirm-ok="Log Charge" data-confirm-class="btn-primary"
|
||||
style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
|
||||
<div class="form-group" style="margin:0;flex:1;min-width:140px;">
|
||||
<label>Date</label>
|
||||
<input type="date" name="charged_date" required>
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;align-self:flex-end;padding-bottom:1rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:normal;cursor:pointer;">
|
||||
<input type="checkbox" name="increment_cycles" value="1" checked>
|
||||
Increment charge cycles
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;flex:2;min-width:160px;">
|
||||
<label>Notes (optional)</label>
|
||||
<input type="text" name="notes" placeholder="e.g. overnight charge">
|
||||
</div>
|
||||
<div style="padding-bottom:1rem;">
|
||||
<button class="btn btn-primary" type="submit">Log Charge for All</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -282,6 +397,8 @@ function addInstallRow() {
|
||||
<div class="card">
|
||||
<h2>Charge All Installed Batteries</h2>
|
||||
<form method="post" action="{{ url_for('device_charge_all', device_id=device.id) }}"
|
||||
data-confirm="Log a charge for all installed batteries in {{ device.name }}?"
|
||||
data-confirm-ok="Log Charge" data-confirm-class="btn-primary"
|
||||
style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
|
||||
<div class="form-group" style="margin:0;flex:1;min-width:140px;">
|
||||
<label>Date</label>
|
||||
@@ -307,7 +424,9 @@ function addInstallRow() {
|
||||
<div class="card">
|
||||
<h2>Install Specific Batteries{% if device.battery_size %} <small class="text-muted" style="font-weight:normal;font-size:0.8rem;">({{ device.battery_size }} only)</small>{% endif %}</h2>
|
||||
{% if available_batteries %}
|
||||
<form method="post" action="{{ url_for('device_install_batch', device_id=device.id) }}">
|
||||
<form method="post" action="{{ url_for('device_install_batch', device_id=device.id) }}"
|
||||
data-confirm="Install the selected batteries into {{ device.name }}?"
|
||||
data-confirm-ok="Install" data-confirm-class="btn-primary">
|
||||
<div style="margin-bottom:0.5rem;">
|
||||
<label style="font-size:0.85rem;cursor:pointer;">
|
||||
<input type="checkbox" id="select-all-avail" onchange="toggleAllAvail(this)"> Select all
|
||||
@@ -407,6 +526,7 @@ function addInstallRow() {
|
||||
<input type="number" id="edit-slots" name="battery_slots" value="{{ device.battery_slots }}" min="1" required>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not device.is_subcomponent() %}
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
|
||||
@@ -427,6 +547,7 @@ function addInstallRow() {
|
||||
placeholder="Enter device type"
|
||||
style="display:{% if device.device_type and device.device_type not in _preset_types %}''{% else %}none{% endif %};margin-top:0.4rem;">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not device.has_children() %}
|
||||
<div class="form-group">
|
||||
<label>Battery Size</label>
|
||||
@@ -452,6 +573,7 @@ function addInstallRow() {
|
||||
style="display:{% if device.battery_size and device.battery_size not in _preset_sizes %}''{% else %}none{% endif %};margin-top:0.4rem;">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not device.is_subcomponent() %}
|
||||
<div class="form-group">
|
||||
<label>Location</label>
|
||||
<select id="edit-location-select" onchange="editLocationSelectChanged(this)">
|
||||
@@ -469,6 +591,7 @@ function addInstallRow() {
|
||||
placeholder="e.g. Living Room, Bedroom"
|
||||
style="display:{% if device.location and device.location not in device_locations|default([]) %}''{% else %}none{% endif %};margin-top:0.4rem;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-notes">Notes</label>
|
||||
|
||||
+50
-11
@@ -5,6 +5,9 @@
|
||||
<h1>Devices</h1>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:flex-start;margin-bottom:0.5rem;">
|
||||
<a class="btn btn-primary btn-sm" href="{{ url_for('device_add') }}">+ Add Device</a>
|
||||
</div>
|
||||
<div id="device-filter-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;">
|
||||
<select id="filter-type" onchange="applyDeviceFilters()"
|
||||
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
|
||||
@@ -57,9 +60,10 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
{% set installed = d.installed_count() %}
|
||||
{% if installed == 0 %}{% set fill_state = 'empty' %}
|
||||
{% elif installed >= d.battery_slots %}{% set fill_state = 'full' %}
|
||||
{% set installed = d.effective_installed_count() %}
|
||||
{% set total_slots = d.effective_slots() %}
|
||||
{% if installed == 0 or total_slots == 0 %}{% set fill_state = 'empty' %}
|
||||
{% elif installed >= total_slots %}{% set fill_state = 'full' %}
|
||||
{% else %}{% set fill_state = 'partial' %}{% endif %}
|
||||
<tr data-type="{{ d.device_type or '' }}"
|
||||
data-battery-size="{{ d.battery_size or '' }}"
|
||||
@@ -68,19 +72,14 @@
|
||||
data-name="{{ d.name|lower }}">
|
||||
<td data-label="Device">
|
||||
<a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a>
|
||||
{% if d.parent %}
|
||||
<br><small class="text-muted">↳ <a href="{{ url_for('device_detail', device_id=d.parent.id) }}">{{ d.parent.name }}</a></small>
|
||||
{% elif d.has_children() %}
|
||||
<br><small class="text-muted">{{ d.children|length }} sub-component{{ 's' if d.children|length != 1 }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Type">{{ d.device_type or '—' }}</td>
|
||||
<td data-label="Size">{{ d.battery_size or '—' }}</td>
|
||||
<td data-label="Location">{{ d.location or '—' }}</td>
|
||||
<td data-label="Slots">{{ d.battery_slots }}</td>
|
||||
<td data-label="Slots">{{ total_slots }}</td>
|
||||
<td data-label="Installed">
|
||||
{{ installed }} / {{ d.battery_slots }}
|
||||
{% if installed >= d.battery_slots %}
|
||||
{{ installed }} / {{ total_slots }}
|
||||
{% if total_slots > 0 and installed >= total_slots %}
|
||||
<span class="badge badge-retired">Full</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -111,6 +110,46 @@
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% for child in d.children %}
|
||||
{% set c_installed = child.installed_count() %}
|
||||
{% if c_installed == 0 or child.battery_slots == 0 %}{% set c_fill = 'empty' %}
|
||||
{% elif c_installed >= child.battery_slots %}{% set c_fill = 'full' %}
|
||||
{% else %}{% set c_fill = 'partial' %}{% endif %}
|
||||
<tr data-type="{{ d.device_type or '' }}"
|
||||
data-battery-size="{{ child.battery_size or '' }}"
|
||||
data-location="{{ d.location or '' }}"
|
||||
data-fill="{{ c_fill }}"
|
||||
data-name="{{ d.name|lower }} {{ child.name|lower }}">
|
||||
<td data-label="Device" style="padding-left:1.75rem;">
|
||||
<span class="text-muted">↳</span>
|
||||
<a href="{{ url_for('device_detail', device_id=child.id) }}">{{ child.name }}</a>
|
||||
</td>
|
||||
<td data-label="Type" class="text-muted">{{ d.device_type or '—' }}</td>
|
||||
<td data-label="Size">{{ child.battery_size or '—' }}</td>
|
||||
<td data-label="Location" class="text-muted">{{ d.location or '—' }}</td>
|
||||
<td data-label="Slots">{{ child.battery_slots }}</td>
|
||||
<td data-label="Installed">
|
||||
{{ c_installed }} / {{ child.battery_slots }}
|
||||
{% if child.battery_slots > 0 and c_installed >= child.battery_slots %}
|
||||
<span class="badge badge-retired">Full</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Brands">
|
||||
{% set c_brands = child.installed_brands() %}
|
||||
{% if c_brands %}
|
||||
{{ c_brands|join(', ') }}
|
||||
{% if child.has_mixed_brands() %}
|
||||
<span class="badge badge-warning">⚠ mixed</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Actions" style="white-space:nowrap;">
|
||||
<a class="btn btn-sm btn-secondary" href="{{ url_for('device_detail', device_id=child.id) }}">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-muted" style="text-align:center;padding:1rem;">No devices yet. <a href="{{ url_for('device_add') }}">Add one.</a></td></tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Home — Battery Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Battery Tracker</h1>
|
||||
|
||||
<h2 style="font-size:1rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.6rem;">Batteries</h2>
|
||||
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
|
||||
<a class="stat-link" href="{{ url_for('battery_list') }}?status=">
|
||||
<div class="card" style="text-align:center;">
|
||||
<div style="font-size:1.8rem;font-weight:700;">{{ total_batteries }}</div>
|
||||
<div class="text-muted">Total</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="stat-link" href="{{ url_for('battery_list') }}?status=available">
|
||||
<div class="card" style="text-align:center;">
|
||||
<div style="font-size:1.8rem;font-weight:700;color:var(--count-available);">{{ avail_count }}</div>
|
||||
<div class="text-muted">Available</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="stat-link" href="{{ url_for('battery_list') }}?status=installed">
|
||||
<div class="card" style="text-align:center;">
|
||||
<div style="font-size:1.8rem;font-weight:700;color:var(--count-installed);">{{ installed_count }}</div>
|
||||
<div class="text-muted">Installed</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="stat-link" href="{{ url_for('battery_list') }}?status=retired">
|
||||
<div class="card" style="text-align:center;">
|
||||
<div style="font-size:1.8rem;font-weight:700;color:var(--count-retired);">{{ retired_count }}</div>
|
||||
<div class="text-muted">Retired</div>
|
||||
</div>
|
||||
</a>
|
||||
{% if ha_enabled and needs_attention.low_pct %}
|
||||
<a href="#needs-attention"
|
||||
onclick="var d=document.getElementById('needs-attention');d.open=true;"
|
||||
style="flex:1;min-width:120px;text-decoration:none;">
|
||||
<div class="card" style="text-align:center;border:2px solid #f59e0b;cursor:pointer;">
|
||||
<div style="font-size:1.8rem;font-weight:700;color:#f59e0b;">{{ needs_attention.low_pct|length }}</div>
|
||||
<div class="text-muted">Low Battery</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.6rem;">Devices</h2>
|
||||
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
|
||||
<a class="stat-link" href="{{ url_for('device_list') }}">
|
||||
<div class="card" style="text-align:center;">
|
||||
<div style="font-size:1.8rem;font-weight:700;">{{ total_devices }}</div>
|
||||
<div class="text-muted">Total</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="stat-link" href="{{ url_for('device_list') }}?fill=full">
|
||||
<div class="card" style="text-align:center;">
|
||||
<div style="font-size:1.8rem;font-weight:700;color:var(--count-installed);">{{ full_devices }}</div>
|
||||
<div class="text-muted">Full</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="stat-link" href="{{ url_for('device_list') }}?fill=partial">
|
||||
<div class="card" style="text-align:center;">
|
||||
<div style="font-size:1.8rem;font-weight:700;color:var(--count-available);">{{ partial_devices }}</div>
|
||||
<div class="text-muted">Partial</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="stat-link" href="{{ url_for('device_list') }}?fill=empty">
|
||||
<div class="card" style="text-align:center;{% if empty_devices > 0 %}border:2px solid #f59e0b;{% endif %}">
|
||||
<div style="font-size:1.8rem;font-weight:700;{% if empty_devices > 0 %}color:#f59e0b;{% else %}color:var(--count-retired);{% endif %}">{{ empty_devices }}</div>
|
||||
<div class="text-muted">Empty</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if total_charges %}
|
||||
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1rem;">
|
||||
<span class="badge" style="font-size:0.875rem;padding:0.35rem 0.75rem;">Charged <strong>{{ total_charges }}</strong>× total</span>
|
||||
<span class="badge" style="font-size:0.875rem;padding:0.35rem 0.75rem;"><strong>{{ charges_last_year }}</strong>× in last year</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set na_low_cap = needs_attention.low_capacity %}
|
||||
{% set na_low_pct = needs_attention.low_pct %}
|
||||
{% if na_low_cap or na_low_pct %}
|
||||
<details id="needs-attention" class="card" style="margin-bottom:1rem;">
|
||||
<summary style="cursor:pointer;font-weight:600;color:var(--text-warning);list-style:none;display:flex;align-items:center;gap:0.5rem;">
|
||||
<span>⚠</span>
|
||||
<span>Needs Attention <span class="badge badge-warning">{{ (na_low_cap|length) + (na_low_pct|length) }}</span></span>
|
||||
</summary>
|
||||
<div style="margin-top:0.75rem;display:flex;flex-wrap:wrap;gap:1.5rem;">
|
||||
{% if na_low_cap %}
|
||||
<div>
|
||||
<div style="font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:0.4rem;">Low Capacity (<80%)</div>
|
||||
{% for b in na_low_cap %}
|
||||
<div style="font-size:0.875rem;margin-bottom:0.25rem;">
|
||||
<a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a>
|
||||
<span class="text-muted">— {{ (b.tested_capacity_mah / b.capacity_mah * 100)|int }}% of rated</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if na_low_pct %}
|
||||
<div>
|
||||
<div style="font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:0.4rem;">Low Battery %</div>
|
||||
{% for b in na_low_pct %}
|
||||
<div style="font-size:0.875rem;margin-bottom:0.25rem;">
|
||||
<a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a>
|
||||
<span class="text-muted">— {{ b.battery_percentage }}%</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;margin-top:0.5rem;max-width:480px;">
|
||||
<a class="btn btn-primary" style="text-align:center;" href="{{ url_for('battery_add') }}">+ Add Battery</a>
|
||||
<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') }}"><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 %}
|
||||
@@ -65,7 +65,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top:1.25rem;display:flex;gap:0.75rem;flex-wrap:wrap;">
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
|
||||
<a href="{{ url_for('home') }}" class="btn btn-primary">Go to Home</a>
|
||||
<a href="{{ url_for('import_page') }}" class="btn btn-secondary">Import Another File</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+404
-9
@@ -8,7 +8,8 @@ The `seeded_client` fixture pre-populates:
|
||||
BrandX 002 (id=3, retired)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import io
|
||||
import json as _json
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -30,17 +31,52 @@ def follow(client, resp):
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Dashboard
|
||||
# Home page
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_dashboard_loads(seeded_client):
|
||||
def test_home_loads(seeded_client):
|
||||
resp = seeded_client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert b"Available" in resp.data
|
||||
assert b"Installed" in resp.data
|
||||
assert b"Devices" in resp.data
|
||||
assert b"BrandX 001" not in resp.data # no battery table on home page
|
||||
|
||||
|
||||
def test_home_needs_attention_shows_low_capacity(client):
|
||||
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||
client.post("/battery/1/edit-details", data={"capacity_mah": "2000"})
|
||||
client.post("/battery/1/capacity-test/add",
|
||||
data={"tested_capacity_mah": "1500", "tested_date": "2026-01-01"})
|
||||
resp = client.get("/")
|
||||
assert b"Needs Attention" in resp.data
|
||||
assert b"Eneloop 001" in resp.data
|
||||
|
||||
|
||||
def test_home_shows_device_counts(seeded_client):
|
||||
resp = seeded_client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert b"Devices" in resp.data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery list
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_battery_list_loads(seeded_client):
|
||||
resp = seeded_client.get("/battery/")
|
||||
assert resp.status_code == 200
|
||||
assert b"BrandX 001" in resp.data
|
||||
assert b"BrandY 001" in resp.data
|
||||
assert b"BrandX 002" in resp.data
|
||||
|
||||
|
||||
def test_battery_list_has_filter_bar(seeded_client):
|
||||
resp = seeded_client.get("/battery/")
|
||||
assert b"filter-status" in resp.data
|
||||
assert b"filter-brand" in resp.data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery add — label preview data
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -117,6 +153,26 @@ def test_edit_notes(seeded_client):
|
||||
# Battery — assign (per-battery, kept for special cases)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_edit_details_percentage_out_of_range_rejected(seeded_client):
|
||||
for bad in ("150", "-5"):
|
||||
resp = seeded_client.post("/battery/1/edit-details",
|
||||
data={"battery_percentage": bad},
|
||||
follow_redirects=True)
|
||||
assert b"between 0 and 100" in resp.data
|
||||
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"] is None
|
||||
assert data["pct_logs"] == []
|
||||
|
||||
|
||||
def test_edit_details_percentage_valid(seeded_client):
|
||||
seeded_client.post("/battery/1/edit-details", data={"battery_percentage": "100"})
|
||||
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(entry["battery_id"] == 1 and entry["source"] == "manual" for entry in data["pct_logs"])
|
||||
|
||||
|
||||
def test_assign_battery(seeded_client):
|
||||
resp = seeded_client.post("/battery/1/assign",
|
||||
data={"device_id": "1"},
|
||||
@@ -168,6 +224,24 @@ def test_unassign_battery(seeded_client):
|
||||
assert b"available" in resp2.data.lower()
|
||||
|
||||
|
||||
def test_unassign_next_honors_local_path(seeded_client):
|
||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||
resp = seeded_client.post("/battery/1/unassign", data={"next": "/device/1"})
|
||||
assert get_location(resp) == "/device/1"
|
||||
|
||||
|
||||
def test_unassign_next_rejects_external_redirect(seeded_client):
|
||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||
resp = seeded_client.post("/battery/1/unassign", data={"next": "//evil.com/phish"})
|
||||
assert resp.headers["Location"] == "/battery/"
|
||||
|
||||
|
||||
def test_unassign_all_next_rejects_external_redirect(seeded_client):
|
||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||
resp = seeded_client.post("/device/1/unassign-all", data={"next": "//evil.com"})
|
||||
assert resp.headers["Location"] == "/device/"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — retire
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -626,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):
|
||||
@@ -703,7 +774,7 @@ def test_import_creates_devices_and_batteries(client):
|
||||
resp = _post_import(client, payload)
|
||||
assert resp.status_code == 200
|
||||
assert b"Import Results" in resp.data
|
||||
dash = client.get("/")
|
||||
dash = client.get("/battery/")
|
||||
assert b"Eneloop 001" in dash.data
|
||||
assert b"RC Car" in dash.data
|
||||
|
||||
@@ -826,6 +897,76 @@ def test_full_roundtrip_export_import(client):
|
||||
assert b"Import Results" in resp.data
|
||||
|
||||
|
||||
def test_export_json_includes_parent_fields(client):
|
||||
_setup_rc_car(client)
|
||||
data = _json.loads(client.get("/export/all.json").data)
|
||||
devs = {d["name"]: d for d in data["devices"]}
|
||||
assert devs["RC Car Set"]["parent_id"] is None
|
||||
assert devs["Remote"]["parent_id"] == devs["RC Car Set"]["id"]
|
||||
assert devs["Remote"]["parent_name"] == "RC Car Set"
|
||||
|
||||
|
||||
def test_import_rebuilds_hierarchy(client):
|
||||
payload = _make_import_payload(devices=[
|
||||
_dev(10, "Hub", battery_slots=0, battery_size=None),
|
||||
_dev(11, "Probe", parent_id=10),
|
||||
])
|
||||
resp = _post_import(client, payload)
|
||||
assert resp.status_code == 200
|
||||
data = _json.loads(client.get("/export/all.json").data)
|
||||
devs = {d["name"]: d for d in data["devices"]}
|
||||
assert devs["Probe"]["parent_id"] == devs["Hub"]["id"]
|
||||
assert devs["Probe"]["parent_name"] == "Hub"
|
||||
# parent's 0 slots survive the import (previously coerced to 1)
|
||||
assert devs["Hub"]["battery_slots"] == 0
|
||||
|
||||
|
||||
def test_import_child_listed_before_parent(client):
|
||||
payload = _make_import_payload(devices=[
|
||||
_dev(11, "Probe", parent_id=10),
|
||||
_dev(10, "Hub", battery_slots=0, battery_size=None),
|
||||
])
|
||||
resp = _post_import(client, payload)
|
||||
assert resp.status_code == 200
|
||||
data = _json.loads(client.get("/export/all.json").data)
|
||||
devs = {d["name"]: d for d in data["devices"]}
|
||||
assert devs["Probe"]["parent_id"] == devs["Hub"]["id"]
|
||||
|
||||
|
||||
def test_import_subcomponent_missing_parent_becomes_top_level(client):
|
||||
payload = _make_import_payload(devices=[_dev(11, "Probe", parent_id=99)])
|
||||
resp = _post_import(client, payload)
|
||||
assert resp.status_code == 200
|
||||
data = _json.loads(client.get("/export/all.json").data)
|
||||
devs = {d["name"]: d for d in data["devices"]}
|
||||
assert devs["Probe"]["parent_id"] is None
|
||||
|
||||
|
||||
def test_import_empty_battery_size_stored_as_null(client):
|
||||
payload = _make_import_payload(devices=[_dev(10, "NoSize", battery_size="")])
|
||||
resp = _post_import(client, payload)
|
||||
assert resp.status_code == 200
|
||||
data = _json.loads(client.get("/export/all.json").data)
|
||||
devs = {d["name"]: d for d in data["devices"]}
|
||||
assert devs["NoSize"]["battery_size"] is None
|
||||
|
||||
|
||||
def test_roundtrip_import_preserves_hierarchy(client):
|
||||
_setup_rc_car(client)
|
||||
data = _json.loads(client.get("/export/all.json").data)
|
||||
# rename everything so the import creates rows instead of skipping
|
||||
for d in data["devices"]:
|
||||
d["name"] = d["name"] + " v2"
|
||||
buf = io.BytesIO(_json.dumps(data).encode())
|
||||
resp = client.post("/import", data={"file": (buf, "export.json")},
|
||||
content_type="multipart/form-data")
|
||||
assert resp.status_code == 200
|
||||
data2 = _json.loads(client.get("/export/all.json").data)
|
||||
devs = {d["name"]: d for d in data2["devices"]}
|
||||
assert devs["Remote v2"]["parent_id"] == devs["RC Car Set v2"]["id"]
|
||||
assert devs["Car v2"]["parent_id"] == devs["RC Car Set v2"]["id"]
|
||||
|
||||
|
||||
def test_device_detail_unassign_all(seeded_client):
|
||||
client = seeded_client
|
||||
# install battery 1 into device 1 (2-slot AA device)
|
||||
@@ -911,6 +1052,19 @@ def test_add_subcomponent(client):
|
||||
assert b"Remote" in resp.data
|
||||
|
||||
|
||||
def test_device_with_children_cannot_become_subcomponent(client):
|
||||
_setup_rc_car(client)
|
||||
# Garage is parent-eligible (top-level, 0 slots, no size)
|
||||
client.post("/device/add", data={"name": "Garage", "battery_slots": "0", "battery_size": ""})
|
||||
resp = client.post("/device/1/edit",
|
||||
data={"name": "RC Car Set", "battery_slots": "0", "parent_id": "4"},
|
||||
follow_redirects=True)
|
||||
assert b"cannot itself become a sub-component" in resp.data
|
||||
data = _json.loads(client.get("/export/all.json").data)
|
||||
devs = {d["name"]: d for d in data["devices"]}
|
||||
assert devs["RC Car Set"]["parent_id"] is None
|
||||
|
||||
|
||||
def test_subcomponent_prevents_deep_nesting(client):
|
||||
_setup_rc_car(client)
|
||||
# id=2 is "Remote", which already has parent_id=1
|
||||
@@ -933,10 +1087,10 @@ def test_subcomponent_install_batteries(client):
|
||||
assert b"full" in resp.data.lower()
|
||||
|
||||
|
||||
def test_dashboard_shows_parent_slash_child(client):
|
||||
def test_battery_list_shows_parent_slash_child(client):
|
||||
_setup_rc_car(client)
|
||||
client.post("/device/2/install-one", data={"battery_id": "1"})
|
||||
resp = client.get("/", follow_redirects=True)
|
||||
resp = client.get("/battery/", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"RC Car Set" in resp.data
|
||||
assert b"Remote" in resp.data
|
||||
@@ -975,3 +1129,244 @@ def test_subcomponent_ha_entity_id(client):
|
||||
resp = client.get("/device/2")
|
||||
assert resp.status_code == 200
|
||||
assert b"Hub" in resp.data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Sub-component hierarchy — parent_key, null type/location, list hiding
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _setup_parent_with_two_children(client):
|
||||
"""Hub (top-level, Sensor type, Living Room) with Sensor A and Sensor B."""
|
||||
client.post("/device/add", data={
|
||||
"name": "Hub", "battery_slots": "0", "battery_size": "",
|
||||
"device_type": "Sensor", "location": "Living Room",
|
||||
}, follow_redirects=True)
|
||||
client.post("/device/add", data={
|
||||
"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "1",
|
||||
}, follow_redirects=True)
|
||||
client.post("/device/add", data={
|
||||
"name": "Sensor B", "battery_slots": "1", "battery_size": "AA", "parent_id": "1",
|
||||
}, follow_redirects=True)
|
||||
|
||||
|
||||
def test_device_list_nests_subcomponents(client):
|
||||
_setup_parent_with_two_children(client)
|
||||
resp = client.get("/device/")
|
||||
assert resp.status_code == 200
|
||||
assert b"Hub" in resp.data
|
||||
# sub-components render as indented child rows under the parent
|
||||
assert b"Sensor A" in resp.data
|
||||
assert b"Sensor B" in resp.data
|
||||
assert "↳".encode() in resp.data
|
||||
# parent name is searchable on child rows
|
||||
assert b'data-name="hub sensor a"' in resp.data
|
||||
|
||||
|
||||
def test_assign_page_shows_parent_prefix(client):
|
||||
_setup_rc_car(client)
|
||||
resp = client.get("/battery/1/assign")
|
||||
assert resp.status_code == 200
|
||||
assert b"RC Car Set /" in resp.data
|
||||
|
||||
|
||||
def test_subcomponent_type_location_null(client):
|
||||
_setup_parent_with_two_children(client)
|
||||
# Sub-component detail shows inherited type/location from parent
|
||||
resp = client.get("/device/2")
|
||||
assert resp.status_code == 200
|
||||
assert b"Sensor" in resp.data
|
||||
assert b"Living Room" in resp.data
|
||||
assert b"inherited" in resp.data
|
||||
# Submitting type/location in edit for sub-component should have no effect
|
||||
client.post("/device/2/edit", data={
|
||||
"name": "Sensor A", "battery_slots": "1",
|
||||
"device_type": "Remote Control", "location": "Bedroom",
|
||||
"parent_id": "1", # keep sub-component relationship
|
||||
})
|
||||
resp2 = client.get("/device/2")
|
||||
assert b"Remote Control" not in resp2.data
|
||||
assert b"Bedroom" not in resp2.data
|
||||
|
||||
|
||||
def test_subcomponent_name_unique_per_parent(client):
|
||||
_setup_parent_with_two_children(client)
|
||||
|
||||
# Duplicate top-level name → 400
|
||||
resp = client.post("/device/add", data={
|
||||
"name": "Hub", "battery_slots": "0", "battery_size": "",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert b"already exists" in resp.data
|
||||
|
||||
# Duplicate child name within same parent → 400
|
||||
resp = client.post("/device/add", data={
|
||||
"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "1",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert b"already exists" in resp.data
|
||||
|
||||
# Same child name under a DIFFERENT parent → success
|
||||
client.post("/device/add", data={
|
||||
"name": "Hub 2", "battery_slots": "0", "battery_size": "",
|
||||
}) # id=4
|
||||
resp = client.post("/device/add", data={
|
||||
"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "4",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_parent_battery_summary_flat(client):
|
||||
_setup_parent_with_two_children(client)
|
||||
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"}) # ids 1, 2
|
||||
client.post("/device/2/install-one", data={"battery_id": "1"})
|
||||
client.post("/device/3/install-one", data={"battery_id": "2"})
|
||||
|
||||
resp = client.get("/device/1")
|
||||
assert resp.status_code == 200
|
||||
# Flat table has Component column heading
|
||||
assert b"Component" in resp.data
|
||||
# Both batteries appear
|
||||
assert b"Eneloop 001" in resp.data
|
||||
assert b"Eneloop 002" in resp.data
|
||||
# Both child names linked in the component column
|
||||
assert b"Sensor A" in resp.data
|
||||
assert b"Sensor B" in resp.data
|
||||
# Flat table heading appears once (not duplicated per sub-component)
|
||||
assert resp.data.count(b"<h2>Installed Batteries</h2>") == 1
|
||||
|
||||
|
||||
def test_device_charge_all_parent(client):
|
||||
_setup_parent_with_two_children(client)
|
||||
client.post("/battery/add", data={"brand": "Eneloop", "label": "E001", "size": "AA"})
|
||||
client.post("/battery/add", data={"brand": "Eneloop", "label": "E002", "size": "AA"})
|
||||
client.post("/device/2/install-one", data={"battery_id": "1"})
|
||||
client.post("/device/3/install-one", data={"battery_id": "2"})
|
||||
resp = client.post(
|
||||
"/device/1/charge-all",
|
||||
data={"charged_date": "2025-06-01", "increment_cycles": "1"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert b"Logged charge for 2 batteri" in resp.data
|
||||
assert b"+cycles" in resp.data
|
||||
|
||||
|
||||
def test_device_charge_all_parent_no_installed(client):
|
||||
_setup_parent_with_two_children(client)
|
||||
resp = client.post(
|
||||
"/device/1/charge-all",
|
||||
data={"charged_date": "2025-06-01"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert b"No installed batteries" in resp.data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — unretire
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_unretire_battery(seeded_client):
|
||||
# id=3 is retired by fixture
|
||||
resp = seeded_client.post("/battery/3/unretire", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"now available again" in resp.data
|
||||
assert b"available" in seeded_client.get("/battery/3").data.lower()
|
||||
|
||||
|
||||
def test_unretire_not_retired(seeded_client):
|
||||
resp = seeded_client.post("/battery/1/unretire", follow_redirects=True)
|
||||
assert b"is not retired" in resp.data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Logbook
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_battery_logbook_add_and_delete(seeded_client):
|
||||
resp = seeded_client.post("/battery/1/logbook/add",
|
||||
data={"body": "Swapped terminals"}, follow_redirects=True)
|
||||
assert b"Swapped terminals" in resp.data
|
||||
resp = seeded_client.post("/battery/1/logbook/1/delete", follow_redirects=True)
|
||||
assert b"Logbook entry deleted" in resp.data
|
||||
assert b"Swapped terminals" not in seeded_client.get("/battery/1").data
|
||||
|
||||
|
||||
def test_battery_logbook_add_empty_body(seeded_client):
|
||||
resp = seeded_client.post("/battery/1/logbook/add",
|
||||
data={"body": " "}, follow_redirects=True)
|
||||
assert b"Entry text is required" in resp.data
|
||||
|
||||
|
||||
def test_device_logbook_add_and_delete(seeded_client):
|
||||
resp = seeded_client.post("/device/1/logbook/add",
|
||||
data={"body": "Cleaned contacts"}, follow_redirects=True)
|
||||
assert b"Cleaned contacts" in resp.data
|
||||
resp = seeded_client.post("/device/1/logbook/1/delete", follow_redirects=True)
|
||||
assert b"Logbook entry deleted" in resp.data
|
||||
assert b"Cleaned contacts" not in seeded_client.get("/device/1").data
|
||||
|
||||
|
||||
def test_device_logbook_add_empty_body(seeded_client):
|
||||
resp = seeded_client.post("/device/1/logbook/add",
|
||||
data={"body": ""}, follow_redirects=True)
|
||||
assert b"Entry text is required" in resp.data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Charge log — delete
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_charge_log_delete_reverts_cycles(seeded_client):
|
||||
seeded_client.post("/battery/1/charge-log/add",
|
||||
data={"charged_date": "2026-06-01", "increment_cycles": "1"})
|
||||
data = _json.loads(seeded_client.get("/export/all.json").data)
|
||||
assert next(b for b in data["batteries"] if b["id"] == 1)["charge_cycles"] == 1
|
||||
log_id = data["charge_logs"][0]["id"]
|
||||
resp = seeded_client.post(f"/battery/1/charge-log/{log_id}/delete",
|
||||
follow_redirects=True)
|
||||
assert b"Charge log entry deleted" in resp.data
|
||||
data = _json.loads(seeded_client.get("/export/all.json").data)
|
||||
assert next(b for b in data["batteries"] if b["id"] == 1)["charge_cycles"] == 0
|
||||
assert data["charge_logs"] == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CSV exports
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_csv_export_routes(seeded_client):
|
||||
expectations = {
|
||||
"/export/batteries.csv": b"label",
|
||||
"/export/devices.csv": b"parent_id,parent_name",
|
||||
"/export/charge-logs.csv": b"charged_date",
|
||||
"/export/capacity-tests.csv": b"tested_capacity_mah",
|
||||
"/export/pct-logs.csv": b"percentage",
|
||||
}
|
||||
for url, marker in expectations.items():
|
||||
resp = seeded_client.get(url)
|
||||
assert resp.status_code == 200, url
|
||||
assert "text/csv" in resp.content_type, url
|
||||
assert marker in resp.data, url
|
||||
resp = seeded_client.get("/export/csv.zip")
|
||||
assert resp.status_code == 200
|
||||
assert "zip" in resp.content_type
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Sub-component edit — type/location stay NULL
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_subcomponent_edit_resave_keeps_nulls(client):
|
||||
_setup_rc_car(client)
|
||||
# device 2 = Remote, sub-component of 1; re-save smuggling in type/location
|
||||
resp = client.post("/device/2/edit", data={
|
||||
"name": "Remote", "battery_slots": "2", "parent_id": "1",
|
||||
"device_type": "Toy", "location": "Garage", "battery_size": "AA",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
data = _json.loads(client.get("/export/all.json").data)
|
||||
rem = next(d for d in data["devices"] if d["name"] == "Remote")
|
||||
assert rem["parent_id"] is not None
|
||||
assert rem["device_type"] is None
|
||||
assert rem["location"] is None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -182,6 +182,62 @@ def test_poll_updates_installed_batteries(ha_app, ha_client_f):
|
||||
mock_ha.get_state.assert_called_once_with("sensor.dev_a_battery")
|
||||
|
||||
|
||||
def test_poll_updates_batteries_in_subcomponents(ha_app, ha_client_f):
|
||||
"""Batteries installed in sub-components are updated via the parent's HA entity."""
|
||||
ha_client_f.post("/device/add", data={"name": "RC Set", "battery_slots": "0", "battery_size": ""})
|
||||
ha_client_f.post("/device/add", data={"name": "Remote", "battery_slots": "2",
|
||||
"battery_size": "AA", "parent_id": "1"})
|
||||
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1", "size": "AA"})
|
||||
ha_client_f.post("/battery/1/assign", data={"device_id": "2"})
|
||||
ha_client_f.post("/device/1/edit", data={
|
||||
"name": "RC Set", "battery_slots": "0", "battery_size": "",
|
||||
"ha_entity_id": "sensor.rc_set_battery"
|
||||
})
|
||||
|
||||
from ha_client import HomeAssistantClient
|
||||
from ha_poller import HaPoller
|
||||
from models import Battery, BatteryPctLog
|
||||
|
||||
mock_ha = MagicMock(spec=HomeAssistantClient)
|
||||
mock_ha.enabled = True
|
||||
mock_ha.get_state.return_value = 37
|
||||
|
||||
Session = _make_session_factory(ha_app)
|
||||
HaPoller(mock_ha, Session, interval=300)._poll_once()
|
||||
|
||||
s = Session()
|
||||
b = s.get(Battery, 1)
|
||||
assert b.battery_percentage == 37
|
||||
logs = s.query(BatteryPctLog).filter_by(battery_id=1, source="poll").all()
|
||||
assert len(logs) == 1 and logs[0].percentage == 37
|
||||
s.close()
|
||||
|
||||
|
||||
def test_poll_clamps_out_of_range_percentage(ha_app, ha_client_f):
|
||||
"""HA sensors occasionally report out-of-range values; the poller clamps to 0-100."""
|
||||
ha_client_f.post("/device/add", data={"name": "Dev E", "battery_slots": "1", "battery_size": "AA"})
|
||||
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
|
||||
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
||||
ha_client_f.post("/device/1/edit", data={
|
||||
"name": "Dev E", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.dev_e"
|
||||
})
|
||||
|
||||
from ha_client import HomeAssistantClient
|
||||
from ha_poller import HaPoller
|
||||
from models import Battery
|
||||
|
||||
mock_ha = MagicMock(spec=HomeAssistantClient)
|
||||
mock_ha.enabled = True
|
||||
mock_ha.get_state.return_value = 150
|
||||
|
||||
Session = _make_session_factory(ha_app)
|
||||
HaPoller(mock_ha, Session, interval=300)._poll_once()
|
||||
|
||||
s = Session()
|
||||
assert s.get(Battery, 1).battery_percentage == 100
|
||||
s.close()
|
||||
|
||||
|
||||
def test_poll_skips_uninstalled_batteries(ha_app, ha_client_f):
|
||||
"""Batteries that are available (not installed) are not updated by the poller."""
|
||||
ha_client_f.post("/device/add", data={"name": "Dev B", "battery_slots": "1", "battery_size": "AA"})
|
||||
@@ -258,7 +314,7 @@ def test_poll_handles_api_error_gracefully(ha_app, ha_client_f):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_dashboard_shows_ha_column_when_enabled(ha_client_f):
|
||||
resp = ha_client_f.get("/")
|
||||
resp = ha_client_f.get("/battery/")
|
||||
assert resp.status_code == 200
|
||||
assert b"ha-pct" in resp.data
|
||||
|
||||
@@ -295,7 +351,7 @@ def test_dashboard_no_warning_for_high_percentage(ha_app, ha_client_f):
|
||||
s.commit()
|
||||
s.close()
|
||||
|
||||
resp = ha_client_f.get("/")
|
||||
resp = ha_client_f.get("/battery/")
|
||||
assert b"85%" in resp.data
|
||||
# badge-warning should NOT appear for this battery's percentage
|
||||
# (may still appear in page for other reasons, so check row contains 85% but not warning badge near it)
|
||||
@@ -428,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
|
||||
|
||||
Reference in New Issue
Block a user