From 8d73dea7ddb6e578f70680d50ebbf8ad72575379 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sun, 16 Aug 2026 13:16:10 +0200 Subject: [PATCH] Live-update dashboard rows and show toasts after sync actions "Sync now" and "Sync all now" now return the freshly reloaded rider row(s) plus an out-of-band toast instead of navigating to a separate result page. The "sync already running" case is a 200 + info toast now instead of a 409 special case. Co-Authored-By: Claude Sonnet 5 --- app/web/operations.py | 59 +++++++++++++++++++++++++++++---- tests/web/test_operations.py | 63 ++++++++++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/app/web/operations.py b/app/web/operations.py index 5099aaf..8292c11 100644 --- a/app/web/operations.py +++ b/app/web/operations.py @@ -14,6 +14,16 @@ router = APIRouter() APP_VERSION = "1.0.0" +def _toast_html(message: str, level: str) -> str: + return templates.get_template("fragments/toast.html").render(message=message, level=level) + + +def _outcome_toast(outcome, label: str) -> tuple[str, str]: + if outcome.status in ("success", "partial"): + return f"{label}: {outcome.imported} imported, {outcome.failed} failed", "success" + return f"{label}: sync failed — {outcome.message or 'unknown error'}", "danger" + + def _normalize_outcome(item): if isinstance(item, Exception): return { @@ -40,13 +50,24 @@ def _normalize_outcome(item): async def manual_sync(request: Request, user_id: int, csrf_token: str = Form(...)): require_admin(request) validate_csrf(request, csrf_token) + token = ensure_csrf_token(request) try: outcome = await request.app.state.sync_manager.sync_user(user_id) + with request.app.state.session_factory() as session: + row = UserRepository(session).dashboard_row(user_id) + label = row.name if row is not None else f"Rider #{user_id}" + message, level = _outcome_toast(outcome, label) except SyncAlreadyRunning: - return HTMLResponse("Sync already running for this user", status_code=409) - return templates.TemplateResponse( - request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]} + with request.app.state.session_factory() as session: + row = UserRepository(session).dashboard_row(user_id) + label = row.name if row is not None else f"Rider #{user_id}" + message, level = f"{label}: sync already running", "info" + row_html = ( + templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=False) + if row is not None + else "" ) + return HTMLResponse(row_html + _toast_html(message, level)) @router.post("/sync-all", response_class=HTMLResponse) @@ -54,9 +75,35 @@ async def manual_sync_all(request: Request, csrf_token: str = Form(...)): require_admin(request) validate_csrf(request, csrf_token) outcomes = await request.app.state.sync_manager.sync_all_enabled() - return templates.TemplateResponse( - request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(o) for o in outcomes]} - ) + token = ensure_csrf_token(request) + + parts = [templates.get_template("fragments/sync_all_form.html").render(csrf_token=token)] + + ok = 0 + failed = 0 + with request.app.state.session_factory() as session: + repository = UserRepository(session) + for item in outcomes: + normalized = _normalize_outcome(item) + if normalized["status"] in ("success", "partial"): + ok += 1 + else: + failed += 1 + outcome_user_id = normalized["user_id"] + if outcome_user_id is not None: + row = repository.dashboard_row(outcome_user_id) + if row is not None: + parts.append( + templates.get_template("fragments/user_row.html").render(row=row, csrf_token=token, oob=True) + ) + + if not outcomes: + parts.append(_toast_html("No riders to sync", "info")) + else: + level = "success" if failed == 0 else "danger" + parts.append(_toast_html(f"Synced {len(outcomes)} riders — {ok} ok, {failed} failed", level)) + + return HTMLResponse("".join(parts)) @router.post("/users/{user_id}/garmin-mfa", response_class=HTMLResponse) diff --git a/tests/web/test_operations.py b/tests/web/test_operations.py index 3ea464b..ceb0993 100644 --- a/tests/web/test_operations.py +++ b/tests/web/test_operations.py @@ -1,6 +1,20 @@ from fastapi.testclient import TestClient -from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository +from app.db.repositories import SchedulerSettingsRepository, SystemLogRepository, UserRepository +from app.sync.states import SyncOutcome + + +def _create_user(app, name="Alex") -> int: + with app.state.session_factory() as session: + user = UserRepository(session).create( + name=name, + enabled=True, + mywhoosh_email_enc="mw", + mywhoosh_password_enc="mw-pw", + garmin_email_enc="g", + garmin_password_enc="g-pw", + ) + return user.id def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None: @@ -12,13 +26,28 @@ def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manage assert fake_sync_manager.user_calls == [1] -def test_manual_sync_reports_already_running(authenticated_client, fake_sync_manager) -> None: +def test_manual_sync_updates_row_and_shows_toast(app, authenticated_client, fake_sync_manager) -> None: + user_id = _create_user(app, "Alex") + + response = authenticated_client.post( + f"/users/{user_id}/sync", + data={"csrf_token": authenticated_client.csrf_token}, + ) + + assert response.status_code == 200 + assert f'id="user-row-{user_id}"' in response.text + assert 'hx-swap-oob="true"' in response.text + assert "Alex" in response.text + assert "0 imported, 0 failed" in response.text + + +def test_manual_sync_reports_already_running_as_toast(authenticated_client, fake_sync_manager) -> None: fake_sync_manager.raise_already_running = True response = authenticated_client.post( "/users/1/sync", data={"csrf_token": authenticated_client.csrf_token}, ) - assert response.status_code == 409 + assert response.status_code == 200 assert "already running" in response.text.lower() @@ -31,6 +60,34 @@ def test_sync_all_calls_shared_manager(authenticated_client, fake_sync_manager) assert fake_sync_manager.all_calls == 1 +def test_sync_all_updates_each_affected_row_and_shows_summary_toast(app, authenticated_client, fake_sync_manager) -> None: + user_id = _create_user(app, "Alex") + + async def fake_sync_all_enabled(): + return [SyncOutcome(user_id=user_id, status="success", discovered=2, imported=2, skipped=0, failed=0)] + + fake_sync_manager.sync_all_enabled = fake_sync_all_enabled + + response = authenticated_client.post( + "/sync-all", + data={"csrf_token": authenticated_client.csrf_token}, + ) + + assert response.status_code == 200 + assert f'id="user-row-{user_id}"' in response.text + assert "Synced 1 riders" in response.text + + +def test_sync_all_shows_toast_when_nothing_to_sync(authenticated_client, fake_sync_manager) -> None: + response = authenticated_client.post( + "/sync-all", + data={"csrf_token": authenticated_client.csrf_token}, + ) + + assert response.status_code == 200 + assert "No riders to sync" in response.text + + def test_manual_sync_requires_admin(client: TestClient) -> None: response = client.post( "/users/1/sync",