Compare commits
12
Commits
ab1340998e
...
ff1a883170
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1a883170 | ||
|
|
0223b820be | ||
|
|
1309c2f5db | ||
|
|
faeb8e8c04 | ||
|
|
b192424978 | ||
|
|
6f0dfb0b7f | ||
|
|
7ed7bb2bb0 | ||
|
|
17bad3f36b | ||
|
|
a9835fee1e | ||
|
|
2a103f52a5 | ||
|
|
d41fe7f4ab | ||
|
|
d1fc80164f |
@@ -41,8 +41,9 @@ journalctl --user -u battery-tracker -f
|
|||||||
|
|
||||||
### Models
|
### 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
|
- `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
|
- `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.
|
||||||
- Helper methods: `Battery.is_available/installed/retired()`, `Device.installed_count()`, `Device.installed_brands()`, `Device.has_mixed_brands()`
|
- 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)
|
### Business rules (enforced in routes, not DB constraints)
|
||||||
- Assigning a retired battery → hard block with flash error
|
- 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`.
|
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)
|
### 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
|
### Adding new columns to existing DB
|
||||||
`create_all()` won't add columns to existing tables. Run via Python:
|
`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) |
|
| Database | SQLite (dev) / MariaDB (prod) |
|
||||||
| WSGI server | Waitress |
|
| WSGI server | Waitress |
|
||||||
| Process manager | systemd user service |
|
| Process manager | systemd user service |
|
||||||
| Tests | pytest (82 tests) |
|
| Tests | pytest (145 tests) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ def _parse_date(val: str) -> str | None:
|
|||||||
return 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))
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
def _record_charge(db, battery, date_val, increment, notes):
|
def _record_charge(db, battery, date_val, increment, notes):
|
||||||
"""Apply one charge event to battery. Caller must call db.commit()."""
|
"""Apply one charge event to battery. Caller must call db.commit()."""
|
||||||
if increment:
|
if increment:
|
||||||
@@ -284,6 +300,9 @@ def create_app(config_object="config"):
|
|||||||
battery.purchase_date = _parse_date(purchase_raw) if purchase_raw else None
|
battery.purchase_date = _parse_date(purchase_raw) if purchase_raw else None
|
||||||
battery.storage_location = f.get("storage_location", "").strip() or None
|
battery.storage_location = f.get("storage_location", "").strip() or None
|
||||||
new_pct = _int("battery_percentage")
|
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:
|
if new_pct != battery.battery_percentage:
|
||||||
battery.battery_percentage = new_pct
|
battery.battery_percentage = new_pct
|
||||||
if new_pct is not None:
|
if new_pct is not None:
|
||||||
@@ -452,8 +471,7 @@ def create_app(config_object="config"):
|
|||||||
battery.device_id = None
|
battery.device_id = None
|
||||||
db.commit()
|
db.commit()
|
||||||
flash(f"{battery.label} unassigned and marked available.", "success")
|
flash(f"{battery.label} unassigned and marked available.", "success")
|
||||||
next_url = request.form.get("next", "")
|
return redirect(_safe_next(url_for("dashboard")))
|
||||||
return redirect(next_url if next_url.startswith("/") else url_for("dashboard"))
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — retire
|
# Battery — retire
|
||||||
@@ -776,16 +794,13 @@ def create_app(config_object="config"):
|
|||||||
if device is None:
|
if device is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
all_devices = db.query(Device).all()
|
all_devices = db.query(Device).all()
|
||||||
brands_q = db.query(Battery.brand).filter(Battery.status == "available")
|
brands_q = _filter_compatible(
|
||||||
if device.battery_size:
|
db.query(Battery.brand).filter(Battery.status == "available"),
|
||||||
brands_q = brands_q.filter(
|
device.battery_size,
|
||||||
(Battery.size == device.battery_size) | (Battery.size == None)
|
|
||||||
)
|
)
|
||||||
brands = [r[0] for r in brands_q.distinct().order_by(Battery.brand).all()]
|
brands = [r[0] for r in brands_q.distinct().order_by(Battery.brand).all()]
|
||||||
avail_q = db.query(Battery).filter_by(status="available")
|
avail_q = _filter_compatible(
|
||||||
if device.battery_size:
|
db.query(Battery).filter_by(status="available"), device.battery_size
|
||||||
avail_q = avail_q.filter(
|
|
||||||
(Battery.size == device.battery_size) | (Battery.size == None)
|
|
||||||
)
|
)
|
||||||
available_batteries = avail_q.order_by(Battery.label).all()
|
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_types = sorted({d.device_type for d in all_devices if d.device_type})
|
||||||
@@ -795,9 +810,10 @@ def create_app(config_object="config"):
|
|||||||
if ha_client.enabled and device.ha_entity_id:
|
if ha_client.enabled and device.ha_entity_id:
|
||||||
ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=1)
|
ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=1)
|
||||||
if ha_live_pct is not None:
|
if ha_live_pct is not None:
|
||||||
|
ha_live_pct = max(0, min(100, ha_live_pct))
|
||||||
changed = False
|
changed = False
|
||||||
for battery in device.batteries:
|
for battery in device.installed_batteries():
|
||||||
if battery.status == "installed" and battery.battery_percentage != ha_live_pct:
|
if battery.battery_percentage != ha_live_pct:
|
||||||
battery.battery_percentage = ha_live_pct
|
battery.battery_percentage = ha_live_pct
|
||||||
db.add(BatteryPctLog(
|
db.add(BatteryPctLog(
|
||||||
battery_id=battery.id,
|
battery_id=battery.id,
|
||||||
@@ -815,10 +831,8 @@ def create_app(config_object="config"):
|
|||||||
for b in child.batteries:
|
for b in child.batteries:
|
||||||
if b.status == "installed":
|
if b.status == "installed":
|
||||||
flat_installed.append((b, child.name, child.id))
|
flat_installed.append((b, child.name, child.id))
|
||||||
avail_q = db.query(Battery).filter_by(status="available")
|
avail_q = _filter_compatible(
|
||||||
if child.battery_size:
|
db.query(Battery).filter_by(status="available"), child.battery_size
|
||||||
avail_q = avail_q.filter(
|
|
||||||
(Battery.size == child.battery_size) | (Battery.size == None)
|
|
||||||
)
|
)
|
||||||
children_avail[child.id] = avail_q.order_by(Battery.label).all()
|
children_avail[child.id] = avail_q.order_by(Battery.label).all()
|
||||||
return render_template("device_detail.html", device=device, brands=brands,
|
return render_template("device_detail.html", device=device, brands=brands,
|
||||||
@@ -881,6 +895,9 @@ def create_app(config_object="config"):
|
|||||||
if new_parent.id == device_id:
|
if new_parent.id == device_id:
|
||||||
flash("A device cannot be its own parent.", "error")
|
flash("A device cannot be its own parent.", "error")
|
||||||
return redirect(url_for("device_detail", device_id=device_id))
|
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():
|
if new_parent.is_subcomponent():
|
||||||
flash("Cannot nest sub-components more than one level deep.", "error")
|
flash("Cannot nest sub-components more than one level deep.", "error")
|
||||||
return redirect(url_for("device_detail", device_id=device_id))
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
@@ -960,10 +977,9 @@ def create_app(config_object="config"):
|
|||||||
|
|
||||||
# Validate availability before writing anything
|
# Validate availability before writing anything
|
||||||
for brand, qty in pairs:
|
for brand, qty in pairs:
|
||||||
avail_q = db.query(func.count(Battery.id)).filter_by(brand=brand, status="available")
|
avail_q = _filter_compatible(
|
||||||
if device.battery_size:
|
db.query(func.count(Battery.id)).filter_by(brand=brand, status="available"),
|
||||||
avail_q = avail_q.filter(
|
device.battery_size,
|
||||||
(Battery.size == device.battery_size) | (Battery.size == None)
|
|
||||||
)
|
)
|
||||||
available_count = avail_q.scalar()
|
available_count = avail_q.scalar()
|
||||||
if available_count < qty:
|
if available_count < qty:
|
||||||
@@ -976,10 +992,9 @@ def create_app(config_object="config"):
|
|||||||
# All checks passed — perform installs
|
# All checks passed — perform installs
|
||||||
total_installed = 0
|
total_installed = 0
|
||||||
for brand, qty in pairs:
|
for brand, qty in pairs:
|
||||||
batch_q = db.query(Battery).filter_by(brand=brand, status="available")
|
batch_q = _filter_compatible(
|
||||||
if device.battery_size:
|
db.query(Battery).filter_by(brand=brand, status="available"),
|
||||||
batch_q = batch_q.filter(
|
device.battery_size,
|
||||||
(Battery.size == device.battery_size) | (Battery.size == None)
|
|
||||||
)
|
)
|
||||||
batch = batch_q.order_by(Battery.id).limit(qty).all()
|
batch = batch_q.order_by(Battery.id).limit(qty).all()
|
||||||
for b in batch:
|
for b in batch:
|
||||||
@@ -1082,10 +1097,7 @@ def create_app(config_object="config"):
|
|||||||
f"Unassigned {count} batter{'y' if count == 1 else 'ies'} from {device.name}.",
|
f"Unassigned {count} batter{'y' if count == 1 else 'ies'} from {device.name}.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
nxt = request.form.get("next", "")
|
return redirect(_safe_next(url_for("device_list")))
|
||||||
if nxt.startswith("/"):
|
|
||||||
return redirect(nxt)
|
|
||||||
return redirect(url_for("device_list"))
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — batch install specific batteries
|
# Devices — batch install specific batteries
|
||||||
@@ -1139,15 +1151,7 @@ def create_app(config_object="config"):
|
|||||||
return redirect(url_for("device_detail", device_id=device_id))
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
increment = 1 if request.form.get("increment_cycles") else 0
|
increment = 1 if request.form.get("increment_cycles") else 0
|
||||||
notes = request.form.get("notes", "").strip() or None
|
notes = request.form.get("notes", "").strip() or None
|
||||||
if device.has_children():
|
installed = device.installed_batteries()
|
||||||
installed = [
|
|
||||||
b
|
|
||||||
for child in device.children
|
|
||||||
for b in child.batteries
|
|
||||||
if b.status == "installed"
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
installed = [b for b in device.batteries if b.status == "installed"]
|
|
||||||
if not installed:
|
if not installed:
|
||||||
flash("No installed batteries to log.", "warning")
|
flash("No installed batteries to log.", "warning")
|
||||||
return redirect(url_for("device_detail", device_id=device_id))
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
@@ -1188,11 +1192,13 @@ def create_app(config_object="config"):
|
|||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
w = csv.writer(buf)
|
w = csv.writer(buf)
|
||||||
w.writerow(["id", "name", "battery_slots", "installed_count",
|
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:
|
for d in rows:
|
||||||
w.writerow([d.id, d.name, d.battery_slots, d.installed_count(),
|
w.writerow([d.id, d.name, d.battery_slots, d.installed_count(),
|
||||||
d.device_type or "", d.battery_size or "",
|
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()
|
return buf.getvalue()
|
||||||
|
|
||||||
def _charge_logs_csv():
|
def _charge_logs_csv():
|
||||||
@@ -1291,7 +1297,9 @@ def create_app(config_object="config"):
|
|||||||
{"id": d.id, "name": d.name, "battery_slots": d.battery_slots,
|
{"id": d.id, "name": d.name, "battery_slots": d.battery_slots,
|
||||||
"installed_count": d.installed_count(), "device_type": d.device_type,
|
"installed_count": d.installed_count(), "device_type": d.device_type,
|
||||||
"battery_size": d.battery_size, "location": d.location,
|
"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
|
for d in devices
|
||||||
],
|
],
|
||||||
"charge_logs": [
|
"charge_logs": [
|
||||||
@@ -1361,29 +1369,33 @@ def create_app(config_object="config"):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# --- devices ---
|
# --- devices ---
|
||||||
for d in data.get("devices", []):
|
def _import_device(d, new_parent_id):
|
||||||
|
nonlocal devices_created, devices_skipped
|
||||||
old_id = d.get("id")
|
old_id = d.get("id")
|
||||||
name = (d.get("name") or "").strip()
|
name = (d.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
devices_skipped += 1
|
devices_skipped += 1
|
||||||
continue
|
return
|
||||||
|
parent_key = new_parent_id if new_parent_id is not None else -1
|
||||||
existing = db.query(Device).filter(
|
existing = db.query(Device).filter(
|
||||||
Device.parent_key == -1, Device.name == name
|
Device.parent_key == parent_key, Device.name == name
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
if old_id is not None:
|
if old_id is not None:
|
||||||
device_id_map[old_id] = existing.id
|
device_id_map[old_id] = existing.id
|
||||||
devices_skipped += 1
|
devices_skipped += 1
|
||||||
else:
|
else:
|
||||||
|
slots = d.get("battery_slots")
|
||||||
new_dev = Device(
|
new_dev = Device(
|
||||||
name = name,
|
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,
|
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,
|
location = d.get("location") or None,
|
||||||
ha_entity_id = d.get("ha_entity_id") or None,
|
ha_entity_id = d.get("ha_entity_id") or None,
|
||||||
notes = d.get("notes") or None,
|
notes = d.get("notes") or None,
|
||||||
parent_key = -1,
|
parent_id = new_parent_id,
|
||||||
|
parent_key = parent_key,
|
||||||
)
|
)
|
||||||
db.add(new_dev)
|
db.add(new_dev)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -1391,6 +1403,17 @@ def create_app(config_object="config"):
|
|||||||
device_id_map[old_id] = new_dev.id
|
device_id_map[old_id] = new_dev.id
|
||||||
devices_created += 1
|
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 ---
|
# --- batteries ---
|
||||||
for b in data.get("batteries", []):
|
for b in data.get("batteries", []):
|
||||||
old_id = b.get("id")
|
old_id = b.get("id")
|
||||||
|
|||||||
+3
-2
@@ -49,8 +49,9 @@ class HaPoller:
|
|||||||
for device in devices:
|
for device in devices:
|
||||||
pct = self._client.get_state(device.ha_entity_id)
|
pct = self._client.get_state(device.ha_entity_id)
|
||||||
if pct is not None:
|
if pct is not None:
|
||||||
for battery in device.batteries:
|
pct = max(0, min(100, pct))
|
||||||
if battery.status == "installed" and battery.battery_percentage != pct:
|
for battery in device.installed_batteries():
|
||||||
|
if battery.battery_percentage != pct:
|
||||||
battery.battery_percentage = pct
|
battery.battery_percentage = pct
|
||||||
session.add(BatteryPctLog(
|
session.add(BatteryPctLog(
|
||||||
battery_id=battery.id,
|
battery_id=battery.id,
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ class Device(Base):
|
|||||||
return sum(c.battery_slots for c in self.children)
|
return sum(c.battery_slots for c in self.children)
|
||||||
return self.battery_slots
|
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):
|
def __repr__(self):
|
||||||
return f"<Device {self.name}>"
|
return f"<Device {self.name}>"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/* ─── 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; }
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;min-height:44px;cursor:pointer;">
|
<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;">
|
<input type="radio" name="device_id" value="{{ device.id }}" style="cursor:pointer;">
|
||||||
<span>
|
<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 class="text-muted">({{ device.installed_count() }}/{{ device.battery_slots }} slots used)</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
+3
-364
@@ -11,311 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="Batteries">
|
<meta name="apple-mobile-web-app-title" content="Batteries">
|
||||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||||
<style>
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
/* ─── Color variables (light mode) ──────────────────────────────── */
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
: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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
@@ -358,64 +55,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="{{ url_for('static', filename='app.js') }}" defer></script>
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.register('/sw.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -105,19 +105,6 @@
|
|||||||
<script>
|
<script>
|
||||||
var prefixMaxNums = {{ prefix_max_nums|default({})|tojson }};
|
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() {
|
function updateLabelPreview() {
|
||||||
var brand = document.getElementById('brand').value.trim();
|
var brand = document.getElementById('brand').value.trim();
|
||||||
var size = document.getElementById('size').value.trim();
|
var size = document.getElementById('size').value.trim();
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────
|
// ── Percentage mini chart ─────────────────────────────────────────────────
|
||||||
(function() {
|
(function() {
|
||||||
var canvas = document.getElementById('pct-chart');
|
var canvas = document.getElementById('pct-chart');
|
||||||
|
|||||||
@@ -129,17 +129,5 @@ function toggleSubcomponents(cb) {
|
|||||||
size.setAttribute('required', '');
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -133,7 +133,9 @@
|
|||||||
{% elif not child_batteries %}
|
{% elif not child_batteries %}
|
||||||
<p class="text-muted" style="margin:0;">No compatible batteries available.</p>
|
<p class="text-muted" style="margin:0;">No compatible batteries available.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url_for('device_install_batch', device_id=child.id) }}">
|
<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;">
|
<div style="margin-bottom:0.4rem;">
|
||||||
<label style="font-size:0.85rem;cursor:pointer;">
|
<label style="font-size:0.85rem;cursor:pointer;">
|
||||||
<input type="checkbox" id="select-all-child-{{ child.id }}" onchange="toggleAllChild{{ child.id }}(this)"> Select all
|
<input type="checkbox" id="select-all-child-{{ child.id }}" onchange="toggleAllChild{{ child.id }}(this)"> Select all
|
||||||
@@ -236,6 +238,8 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Charge All Installed Batteries</h2>
|
<h2>Charge All Installed Batteries</h2>
|
||||||
<form method="post" action="{{ url_for('device_charge_all', device_id=device.id) }}"
|
<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;">
|
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;">
|
<div class="form-group" style="margin:0;flex:1;min-width:140px;">
|
||||||
<label>Date</label>
|
<label>Date</label>
|
||||||
@@ -393,6 +397,8 @@ function addInstallRow() {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Charge All Installed Batteries</h2>
|
<h2>Charge All Installed Batteries</h2>
|
||||||
<form method="post" action="{{ url_for('device_charge_all', device_id=device.id) }}"
|
<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;">
|
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;">
|
<div class="form-group" style="margin:0;flex:1;min-width:140px;">
|
||||||
<label>Date</label>
|
<label>Date</label>
|
||||||
@@ -418,7 +424,9 @@ function addInstallRow() {
|
|||||||
<div class="card">
|
<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>
|
<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 %}
|
{% 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;">
|
<div style="margin-bottom:0.5rem;">
|
||||||
<label style="font-size:0.85rem;cursor:pointer;">
|
<label style="font-size:0.85rem;cursor:pointer;">
|
||||||
<input type="checkbox" id="select-all-avail" onchange="toggleAllAvail(this)"> Select all
|
<input type="checkbox" id="select-all-avail" onchange="toggleAllAvail(this)"> Select all
|
||||||
|
|||||||
@@ -69,9 +69,6 @@
|
|||||||
data-name="{{ d.name|lower }}">
|
data-name="{{ d.name|lower }}">
|
||||||
<td data-label="Device">
|
<td data-label="Device">
|
||||||
<a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a>
|
<a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a>
|
||||||
{% if d.has_children() %}
|
|
||||||
<br><small class="text-muted">{{ d.children|length }} sub-component{{ 's' if d.children|length != 1 }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Type">{{ d.device_type or '—' }}</td>
|
<td data-label="Type">{{ d.device_type or '—' }}</td>
|
||||||
<td data-label="Size">{{ d.battery_size or '—' }}</td>
|
<td data-label="Size">{{ d.battery_size or '—' }}</td>
|
||||||
@@ -110,6 +107,46 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 %}
|
{% 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>
|
<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 %}
|
{% endfor %}
|
||||||
|
|||||||
+245
-3
@@ -117,6 +117,26 @@ def test_edit_notes(seeded_client):
|
|||||||
# Battery — assign (per-battery, kept for special cases)
|
# 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(l["battery_id"] == 1 and l["source"] == "manual" for l in data["pct_logs"])
|
||||||
|
|
||||||
|
|
||||||
def test_assign_battery(seeded_client):
|
def test_assign_battery(seeded_client):
|
||||||
resp = seeded_client.post("/battery/1/assign",
|
resp = seeded_client.post("/battery/1/assign",
|
||||||
data={"device_id": "1"},
|
data={"device_id": "1"},
|
||||||
@@ -168,6 +188,24 @@ def test_unassign_battery(seeded_client):
|
|||||||
assert b"available" in resp2.data.lower()
|
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"] == "/"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
# Battery — retire
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -826,6 +864,76 @@ def test_full_roundtrip_export_import(client):
|
|||||||
assert b"Import Results" in resp.data
|
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):
|
def test_device_detail_unassign_all(seeded_client):
|
||||||
client = seeded_client
|
client = seeded_client
|
||||||
# install battery 1 into device 1 (2-slot AA device)
|
# install battery 1 into device 1 (2-slot AA device)
|
||||||
@@ -911,6 +1019,19 @@ def test_add_subcomponent(client):
|
|||||||
assert b"Remote" in resp.data
|
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):
|
def test_subcomponent_prevents_deep_nesting(client):
|
||||||
_setup_rc_car(client)
|
_setup_rc_car(client)
|
||||||
# id=2 is "Remote", which already has parent_id=1
|
# id=2 is "Remote", which already has parent_id=1
|
||||||
@@ -995,13 +1116,24 @@ def _setup_parent_with_two_children(client):
|
|||||||
}, follow_redirects=True)
|
}, follow_redirects=True)
|
||||||
|
|
||||||
|
|
||||||
def test_device_list_hides_subcomponents(client):
|
def test_device_list_nests_subcomponents(client):
|
||||||
_setup_parent_with_two_children(client)
|
_setup_parent_with_two_children(client)
|
||||||
resp = client.get("/device/")
|
resp = client.get("/device/")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Hub" in resp.data
|
assert b"Hub" in resp.data
|
||||||
assert b"Sensor A" not in resp.data
|
# sub-components render as indented child rows under the parent
|
||||||
assert b"Sensor B" not in resp.data
|
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):
|
def test_subcomponent_type_location_null(client):
|
||||||
@@ -1095,3 +1227,113 @@ def test_device_charge_all_parent_no_installed(client):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"No installed batteries" in resp.data
|
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
|
||||||
|
|||||||
@@ -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")
|
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):
|
def test_poll_skips_uninstalled_batteries(ha_app, ha_client_f):
|
||||||
"""Batteries that are available (not installed) are not updated by the poller."""
|
"""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"})
|
ha_client_f.post("/device/add", data={"name": "Dev B", "battery_slots": "1", "battery_size": "AA"})
|
||||||
|
|||||||
Reference in New Issue
Block a user