Skip to content

Commit ceb9e83

Browse files
jdrumgooleclaude
andcommitted
Fix 500 on setup wizard when username already exists in target DB
If an admin pointed the wizard at a MongoDB that already contained a user with the chosen username (e.g. recovering after losing the local solisdash.toml), `create_user` raised `DuplicateKeyError` from the `username_unique` index and FastAPI returned a 500. The wizard now probes the target users collection for the requested username before writing the toml and renders a friendly inline error pointing the admin at /login. A try/except around `create_user` covers the index race for completeness. Also adds an initial CHANGELOG.md covering 0.1.x through 0.7.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7e3f607 commit ceb9e83

6 files changed

Lines changed: 202 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Changelog
2+
3+
All notable changes to **Solisdash** are documented here. Version numbers
4+
follow [Semantic Versioning](https://semver.org/). The package is published
5+
to PyPI on every `vX.Y.Z` tag push.
6+
7+
## 0.7.1 — 2026-05-16
8+
9+
### Fixed
10+
- Submitting the setup wizard against a MongoDB that already contained a
11+
user with the chosen username raised `DuplicateKeyError` and returned a
12+
500. The wizard now probes for a username collision before persisting
13+
anything and renders a friendly inline error pointing the user at
14+
`/login`. A `DuplicateKeyError` fallback covers the index race.
15+
16+
## 0.7.0 — 2026-05-16
17+
18+
### Changed
19+
- All configuration now lives inside the webview. The CLI no longer prompts
20+
for anything — `solisdash` boots straight into the pywebview window and
21+
silently generates `SESSION_SECRET` on first run.
22+
23+
### Added
24+
- First-run **setup wizard** at `/setup`: a single-page form with three
25+
sections (MongoDB, SolisCloud, Administrator account). Each section has
26+
an HTMX **Test** button that probes the value inline before save. After
27+
save the user is auto-signed-in and dropped on the dashboard.
28+
- **Settings page** at `/settings` (linked from the nav as ⚙ Settings):
29+
re-edit the same values, re-test, or reset the configuration. Reset
30+
wipes `solisdash.toml` and re-runs the wizard; MongoDB data
31+
(users, samples, alarms, daily totals) is left alone.
32+
- **`~/.config/solisdash/solisdash.toml`** as the persisted config store,
33+
XDG-aware and chmodded to `0600`. Loaded via pydantic-settings'
34+
`TomlConfigSettingsSource`. Environment variables still take precedence
35+
over the file, and a project-local `.env` is still read for dev.
36+
37+
### Removed
38+
- The interactive CLI setup helpers (`solisdash setup`, prompts on first
39+
boot). The `add-user` subcommand stays for post-install user management.
40+
41+
## 0.6.2 — 2026-05-15
42+
43+
### Changed
44+
- Dropped `invoke` as a runtime dependency — it was unused in `src/` and
45+
shipped only because the build tasks lived in the package. Now in the
46+
`dev` extra.
47+
48+
## 0.6.1 — 2026-05-15
49+
50+
### Fixed
51+
- Signing in on a freshly-installed instance with empty SolisCloud
52+
credentials raised `RuntimeError` from `get_solis_client`, producing a
53+
500. The client now constructs even with empty creds; live-tile
54+
requests fall back to a friendly "SolisCloud rejected the call" alert
55+
that nudges the admin to finish configuration.
56+
57+
## 0.6.0 — 2026-05-15
58+
59+
### Added
60+
- First-run onboarding: when no `.env` is present and stdin is a TTY,
61+
the CLI prompts the admin to enter Mongo + SolisCloud creds and writes
62+
them to a user-level config file. Superseded by the in-browser wizard
63+
in 0.7.0.
64+
65+
## 0.5.0 — 2026-05-15
66+
67+
### Added
68+
- First-run setup wizard at `/setup` (server-side, no Mongo creds yet)
69+
so a fresh install can create its admin account from the browser
70+
instead of the CLI.
71+
- `solisdash add-user` CLI subcommand for creating additional accounts
72+
post-install.
73+
74+
## 0.4.0 — 2026-05-15
75+
76+
### Changed
77+
- Lowered the minimum Python version from 3.12 to **3.10** so the
78+
package installs on the broader range of LTS Pythons. `tomli` is
79+
pulled in as a fallback for `tomllib` on 3.10.
80+
81+
## 0.3.2 — 2026-05-15
82+
83+
### Removed
84+
- The obsolete `pymongo[srv]` extra. Recent `pymongo` releases bundle
85+
SRV resolution by default; the extra was a no-op causing a pip
86+
warning.
87+
88+
## 0.3.1 — 2026-05-15
89+
90+
### Added
91+
- GitHub Actions release workflow (`.github/workflows/release.yml`):
92+
pushing a `vX.Y.Z` tag builds with `uv build` and publishes the sdist
93+
+ wheel to PyPI via OIDC trusted publishing. No API tokens, no local
94+
`uv publish`.
95+
96+
## 0.2.x — 2026-05-13 → 2026-05-14
97+
98+
Pre-release scaffolding. Highlights from the run-up to the first
99+
PyPI-published version:
100+
101+
- `solisdash` CLI launcher with native pywebview window + menu.
102+
- AGPL-3.0-or-later license.
103+
- GitHub Actions CI (ruff, mypy, pytest with a `mongo:7` service).
104+
- Public README and CI badge.
105+
106+
## 0.1.x — 2026-05-13
107+
108+
Initial development. Highlights:
109+
110+
- FastAPI app scaffold, session-cookie auth, Jinja shell.
111+
- SolisCloud client with HMAC-SHA1 signing (bare `application/json`
112+
Content-Type, no separate `KeyId` header, `alarmList` unwrapping
113+
fixed).
114+
- MongoDB layer with per-worker test fixture.
115+
- Live-tile dashboard with HTMX polling and station-samples fallback.
116+
- Poller + APScheduler + token-bucket rate limiter, with `poll-once`
117+
and `backfill` invoke tasks.
118+
- History charts: day, month, year, all-time (Mongo-only, no SolisCloud
119+
calls).
120+
- Alarm feed, CSV export, dark-mode toggle, `/ready` probe.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "solisdash"
3-
version = "0.7.0"
3+
version = "0.7.1"
44
description = "Self-hosted dashboard for the SolisCloud (Ginlong Solis) platform"
55
readme = "README.md"
66
requires-python = ">=3.10"

‎src/solisdash/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.7.0"
1+
__version__ = "0.7.1"

‎src/solisdash/app.py‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from fastapi.templating import Jinja2Templates
1818
from pymongo import AsyncMongoClient
1919
from pymongo.asynchronous.database import AsyncDatabase
20+
from pymongo.errors import DuplicateKeyError
2021
from starlette.middleware.sessions import SessionMiddleware
2122

2223
from solisdash import __version__
@@ -520,24 +521,42 @@ def _err(msg: str, **extras: str) -> HTMLResponse:
520521
if not password:
521522
return _err("Password must not be empty.")
522523

523-
# Verify the supplied URI works before we persist anything.
524+
target_uri = mongo_uri.strip()
525+
target_db = mongo_db.strip() or "solis"
526+
target_username = username.strip()
527+
528+
# Verify the supplied URI works AND check for a username collision
529+
# *before* we persist anything. Without this guard, pointing the wizard
530+
# at an already-populated database (e.g. recovering after losing the
531+
# local toml) raises DuplicateKeyError as a 500 once we hit the index.
524532
try:
525533
probe: AsyncMongoClient[dict[str, Any]] = AsyncMongoClient(
526-
mongo_uri, serverSelectionTimeoutMS=4000
534+
target_uri, serverSelectionTimeoutMS=4000
527535
)
528536
try:
529-
await probe[mongo_db].command("ping")
537+
probe_db = probe[target_db]
538+
await probe_db.command("ping")
539+
collision = await probe_db["users"].find_one(
540+
{"username": target_username}, projection={"_id": 1}
541+
)
530542
finally:
531543
await probe.close()
532544
except Exception as exc:
533545
return _err(f"MongoDB connection failed: {type(exc).__name__}: {exc}")
534546

547+
if collision is not None:
548+
return _err(
549+
f"A user named “{target_username}” already exists in this database. "
550+
"Pick a different username, or sign in to the existing account "
551+
"from the /login page after saving."
552+
)
553+
535554
# Persist everything that's non-empty.
536555
settings = get_settings()
537556
write_toml(
538557
{
539-
"SOLIS_MONGODB_URI": mongo_uri.strip(),
540-
"SOLIS_MONGODB_DB": mongo_db.strip() or "solis",
558+
"SOLIS_MONGODB_URI": target_uri,
559+
"SOLIS_MONGODB_DB": target_db,
541560
"SOLIS_API_URL": solis_api_url.strip() or settings.SOLIS_API_URL,
542561
"SOLIS_KEY_ID": solis_key_id.strip(),
543562
"SOLIS_KEYSECRET": solis_keysecret,
@@ -550,12 +569,19 @@ def _err(msg: str, **extras: str) -> HTMLResponse:
550569
# Create the first admin in the freshly-configured Mongo.
551570
db = await get_db(request)
552571
await ensure_indexes(db)
553-
await create_user(
554-
db, username=username.strip(), password=password, role="admin"
555-
)
572+
try:
573+
await create_user(db, username=target_username, password=password, role="admin")
574+
except DuplicateKeyError:
575+
# Pre-check above usually catches this; the index also enforces it,
576+
# so handle the race anyway rather than 500ing.
577+
return _err(
578+
f"A user named “{target_username}” already exists in this database. "
579+
"Pick a different username, or sign in to the existing account "
580+
"from the /login page."
581+
)
556582

557583
# Auto-sign-in — saves the user re-typing the password they just chose.
558-
session_login(request, {"username": username.strip(), "role": "admin"})
584+
session_login(request, {"username": target_username, "role": "admin"})
559585
return redirect_to("/")
560586

561587

‎tests/test_app_setup.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,50 @@ async def _not_done() -> bool:
254254
assert "MongoDB connection failed" in r.text
255255

256256

257+
async def test_setup_post_rejects_existing_username_in_target_db(
258+
auth_client: httpx.AsyncClient,
259+
clean_db: AsyncDatabase[dict[str, Any]],
260+
mongo_uri: str,
261+
test_db_name: str,
262+
monkeypatch: pytest.MonkeyPatch,
263+
tmp_path: Any,
264+
) -> None:
265+
"""Pointing the wizard at a DB that already has the chosen username
266+
must render a friendly error, not blow up with DuplicateKeyError."""
267+
268+
async def _not_done() -> bool:
269+
return False
270+
271+
monkeypatch.setattr(app_module, "_setup_done", _not_done)
272+
# Keep any toml writes off the user's real home directory.
273+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
274+
275+
# Seed the target DB with a user the form will collide with.
276+
await create_user(clean_db, username="jdrumgoole", password="x", role="admin")
277+
278+
r = await auth_client.post(
279+
"/setup",
280+
data={
281+
"mongo_uri": mongo_uri,
282+
"mongo_db": test_db_name,
283+
"solis_api_url": "https://api/",
284+
"solis_key_id": "",
285+
"solis_keysecret": "",
286+
"username": "jdrumgoole",
287+
"password": "hunter2",
288+
"confirm": "hunter2",
289+
},
290+
follow_redirects=False,
291+
)
292+
assert r.status_code == 400
293+
assert "already exists" in r.text
294+
assert "jdrumgoole" in r.text
295+
# Pre-check fires before write_toml, so the toml was not written.
296+
assert not (tmp_path / "solisdash" / "solisdash.toml").exists()
297+
# And the seeded user remains untouched.
298+
assert await clean_db["users"].count_documents({"username": "jdrumgoole"}) == 1
299+
300+
257301
# --- /settings page -------------------------------------------------------
258302

259303

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)