Files
battery-tracker-app/CLAUDE.md
T

82 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Commands
```bash
# Activate the venv (required before all commands below)
source .venv/bin/activate
# Run all acceptance tests
pytest tests/ -v
# Run a single test
pytest tests/test_acceptance.py::test_assign_battery -v
# Seed the database (safe to skip if batteries.db already exists)
python seed.py
# Start the dev server
flask run
# Start production server (same as systemd service uses)
waitress-serve --host=127.0.0.1 --port=5000 app:app
# Install/reinstall systemd user service
bash sbin/install-service.sh
# Service management
systemctl --user start|stop|restart|status battery-tracker
journalctl --user -u battery-tracker -f
```
## Architecture
### Request lifecycle
`app.py` contains `create_app(config_object)` which builds the Flask app, creates the SQLAlchemy engine from `config.SQLALCHEMY_DATABASE_URI`, calls `Base.metadata.create_all(engine)`, and wires a `scoped_session` as the `db` local. All routes close over this `db` via `@app.teardown_appcontext`. There is no Flask-SQLAlchemy — the ORM session is raw SQLAlchemy so that `models.py` can be imported by `migrate_to_mariadb.py` without an app context.
### Database config
`config.py` reads `DATABASE_URL` from the environment, falling back to `sqlite:///batteries.db`. Swapping to MariaDB is entirely a matter of setting that env var — no code changes needed. All column types are restricted to `Integer`, `String`, `Text`, `Boolean`, `DateTime` for MariaDB compatibility. Status is stored as `String(20)` (not `Enum`) to avoid DDL differences between SQLite and MariaDB.
### 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, 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
- Assigning to a full device → hard block with flash error
- Mixed brands on same device → flash warning, assignment still proceeds
- Deleting a battery → requires GET confirmation page first, then POST
- Bulk install into device: capacity check before write; batteries already in target device are skipped; retired batteries are skipped
- Unretire: sets status back to `available`
- Unassign with `next` form field → redirects to that URL (must start with `/`); falls back to dashboard
- Logging a charge entry → sets `battery.battery_percentage = 100`
- Retired batteries are excluded from `needs_attention` and `low_pct` warnings — both use the `active` list (`status in ("available", "installed")`); the template-side `low_pct` filter and the in-table ⚠ badge also guard against retired status
### Dashboard filtering
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 (clamped to 0100), 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:
```python
import sqlite3
conn = sqlite3.connect('batteries.db')
conn.execute('ALTER TABLE <table> ADD COLUMN <col> <type>')
conn.commit(); conn.close()
```
Always snapshot the DB first: `cp batteries.db batteries.db.$(date +%Y-%m-%d).snapshot`
### Testing
Tests use a temporary file-based SQLite DB (via `tempfile.mkstemp`) created fresh per test — avoids SQLite in-memory per-connection isolation issues. The `seeded_client` fixture in `conftest.py` pre-populates via HTTP POST calls (not direct DB access), so tests exercise the full stack. Battery IDs in tests are positional (id=1 is always the first battery POSTed by the fixture).
`TestConfig` sets `HOMEASSISTANT_URL = None` to prevent the HA poller thread from starting during tests. HA integration tests live in `tests/test_ha_integration.py` and use a separate `ha_app` fixture with a fake HA URL; they call `poller._poll_once()` directly rather than waiting for the background thread to fire. HA API calls are mocked with `unittest.mock.patch("ha_client.requests.get")`.
### MariaDB migration
`migrate_to_mariadb.py` opens two SQLAlchemy sessions simultaneously (SQLite source, MariaDB destination), migrates Devices first (FK dependency), then Batteries with explicit `id=` values to preserve FK links, then resets `AUTO_INCREMENT` via `text("ALTER TABLE ...")`. Takes `MARIADB_URL` from env or CLI arg. See `MIGRATION.md` for the full procedure.