diff --git a/app/db/repositories.py b/app/db/repositories.py
index 7f08d4d..ac95348 100644
--- a/app/db/repositories.py
+++ b/app/db/repositories.py
@@ -1,3 +1,4 @@
+from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import and_, or_, select
@@ -7,6 +8,18 @@ from sqlalchemy.orm import Session
from app.db.models import Activity, ActivityStatus, SyncRun, SyncRunStatus, SyncUser, utcnow
+@dataclass(frozen=True)
+class UserDashboardRow:
+ id: int
+ name: str
+ enabled: bool
+ health_state: str
+ action_reason: str | None
+ last_sync_at: datetime | None
+ last_activity_name: str | None
+ last_activity_status: str | None
+
+
class UserRepository:
def __init__(self, session: Session) -> None:
self.session = session
@@ -32,6 +45,28 @@ class UserRepository:
self.session.commit()
return user
+ def dashboard_rows(self) -> list[UserDashboardRow]:
+ users = self.list_all()
+ rows = []
+ for user in users:
+ last_run = self.session.scalar(
+ select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1)
+ )
+ last_activity = self.session.scalar(
+ select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1)
+ )
+ rows.append(UserDashboardRow(
+ id=user.id,
+ name=user.name,
+ enabled=user.enabled,
+ health_state=user.health_state.value,
+ action_reason=user.action_reason,
+ last_sync_at=last_run.finished_at if last_run else None,
+ last_activity_name=last_activity.activity_name if last_activity else None,
+ last_activity_status=last_activity.status.value if last_activity else None,
+ ))
+ return rows
+
class ActivityRepository:
def __init__(self, session: Session) -> None:
diff --git a/app/main.py b/app/main.py
index 4223c19..eae2fd9 100644
--- a/app/main.py
+++ b/app/main.py
@@ -11,6 +11,7 @@ from app.mywhoosh.client import MyWhooshClient
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager
from app.sync.scheduler import SyncScheduler
+from app.web.operations import router as operations_router
from app.web.routes import router as web_router
@@ -63,6 +64,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
https_only=resolved.session_https_only,
)
app.include_router(web_router)
+ app.include_router(operations_router)
@app.get("/healthz")
def healthz() -> dict[str, str]:
diff --git a/app/web/operations.py b/app/web/operations.py
new file mode 100644
index 0000000..9e03e16
--- /dev/null
+++ b/app/web/operations.py
@@ -0,0 +1,78 @@
+from fastapi import APIRouter, Form, Request
+from fastapi.responses import HTMLResponse
+from sqlalchemy import func, select
+
+from app.auth.admin import require_admin
+from app.auth.csrf import ensure_csrf_token, validate_csrf
+from app.db.models import Activity
+from app.db.repositories import UserRepository
+from app.sync.manager import SyncAlreadyRunning
+from app.web.routes import templates
+
+router = APIRouter()
+
+APP_VERSION = "1.0.0"
+
+
+def _normalize_outcome(item):
+ if isinstance(item, Exception):
+ return {
+ "status": "error",
+ "user_id": None,
+ "discovered": 0,
+ "imported": 0,
+ "skipped": 0,
+ "failed": 0,
+ "message": str(item),
+ }
+ return {
+ "status": item.status,
+ "user_id": item.user_id,
+ "discovered": item.discovered,
+ "imported": item.imported,
+ "skipped": item.skipped,
+ "failed": item.failed,
+ "message": item.message,
+ }
+
+
+@router.post("/users/{user_id}/sync", response_class=HTMLResponse)
+async def manual_sync(request: Request, user_id: int, csrf_token: str = Form(...)):
+ require_admin(request)
+ validate_csrf(request, csrf_token)
+ try:
+ outcome = await request.app.state.sync_manager.sync_user(user_id)
+ 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)]}
+ )
+
+
+@router.post("/sync-all", response_class=HTMLResponse)
+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]}
+ )
+
+
+@router.get("/system", response_class=HTMLResponse)
+def system_page(request: Request):
+ require_admin(request)
+ settings = request.app.state.settings
+ scheduler = request.app.state.scheduler
+ with request.app.state.session_factory() as session:
+ user_count = len(UserRepository(session).list_all())
+ activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0
+ return templates.TemplateResponse(request, "system.html", {
+ "csrf_token": ensure_csrf_token(request),
+ "app_version": APP_VERSION,
+ "sync_interval_minutes": settings.sync_interval_minutes,
+ "last_tick": scheduler.last_tick,
+ "next_tick": scheduler.next_tick,
+ "user_count": user_count,
+ "activity_count": activity_count,
+ })
diff --git a/app/web/routes.py b/app/web/routes.py
index 2d4e242..1edd7af 100644
--- a/app/web/routes.py
+++ b/app/web/routes.py
@@ -62,11 +62,11 @@ def login(
def dashboard(request: Request):
require_admin(request)
with request.app.state.session_factory() as session:
- users = UserRepository(session).list_all()
+ rows = UserRepository(session).dashboard_rows()
return templates.TemplateResponse(
request,
"dashboard.html",
- {"users": users, "csrf_token": ensure_csrf_token(request)},
+ {"rows": rows, "csrf_token": ensure_csrf_token(request)},
)
diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html
index 320affb..c47920b 100644
--- a/app/web/templates/dashboard.html
+++ b/app/web/templates/dashboard.html
@@ -4,13 +4,28 @@
{% block content %}
Dashboard
-Add user
+Add user | System
+
+
+
- {% for user in users %}
+ {% for row in rows %}
-
- {{ user.name }}
- — {{ "enabled" if user.enabled else "disabled" }}
- — {{ user.health_state.value }}
+ {{ row.name }}
+ — {{ "enabled" if row.enabled else "disabled" }}
+ — {{ row.health_state }}
+ — last sync: {{ row.last_sync_at or "-" }}
+ — last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})
+ {% if row.action_reason == "garmin_mfa_required" %}
+ Garmin MFA required — resolve
+ {% endif %}
+
{% else %}
- No users yet.
diff --git a/app/web/templates/fragments/sync_result.html b/app/web/templates/fragments/sync_result.html
new file mode 100644
index 0000000..cf6c842
--- /dev/null
+++ b/app/web/templates/fragments/sync_result.html
@@ -0,0 +1,17 @@
+
+ {% for outcome in outcomes %}
+ -
+ User {{ outcome.user_id if outcome.user_id is not none else "unknown" }}:
+ status={{ outcome.status }}
+ discovered={{ outcome.discovered }}
+ imported={{ outcome.imported }}
+ skipped={{ outcome.skipped }}
+ failed={{ outcome.failed }}
+ {% if outcome.message %}
+ — {{ outcome.message }}
+ {% endif %}
+
+ {% else %}
+ - No outcomes.
+ {% endfor %}
+
diff --git a/app/web/templates/system.html b/app/web/templates/system.html
new file mode 100644
index 0000000..30fe12d
--- /dev/null
+++ b/app/web/templates/system.html
@@ -0,0 +1,33 @@
+{% extends "base.html" %}
+
+{% block title %}System - MyWhoosh Garmin Sync{% endblock %}
+
+{% block content %}
+System
+Back to dashboard
+
+
+ - Application version
+ - {{ app_version }}
+
+ - Sync interval (minutes)
+ - {{ sync_interval_minutes }}
+
+ - Last scheduler tick
+ - {{ last_tick or "-" }}
+
+ - Next scheduler tick
+ - {{ next_tick or "-" }}
+
+ - User count
+ - {{ user_count }}
+
+ - Activity count
+ - {{ activity_count }}
+
+
+
+{% endblock %}
diff --git a/tests/conftest.py b/tests/conftest.py
index c7322df..9d1ed73 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -45,7 +45,7 @@ def sync_run_repository(db_session: Session) -> SyncRunRepository:
@pytest.fixture
-def client(tmp_path: Path) -> TestClient:
+def app(tmp_path: Path):
settings = Settings(
ADMIN_PASSWORD="admin-secret",
SECRET_KEY="0123456789abcdef0123456789abcdef",
@@ -54,11 +54,63 @@ def client(tmp_path: Path) -> TestClient:
DATABASE_URL=f"sqlite:///{tmp_path / 'app.db'}",
SYNC_INTERVAL_MINUTES=5,
)
- app = create_app(settings)
+ application = create_app(settings)
try:
- yield TestClient(app)
+ yield application
finally:
- app.state.db_engine.dispose()
+ application.state.db_engine.dispose()
+
+
+@pytest.fixture
+def client(app) -> TestClient:
+ return TestClient(app)
+
+
+def _extract_csrf(html: str) -> str:
+ marker = 'name="csrf_token" value="'
+ start = html.index(marker) + len(marker)
+ end = html.index('"', start)
+ return html[start:end]
+
+
+class FakeSyncManager:
+ def __init__(self) -> None:
+ self.user_calls: list[int] = []
+ self.all_calls = 0
+ self.raise_already_running = False
+ self.mfa_calls: list[tuple[int, str]] = []
+
+ async def sync_user(self, user_id: int, mfa_code: str | None = None):
+ if self.raise_already_running:
+ from app.sync.manager import SyncAlreadyRunning
+
+ raise SyncAlreadyRunning(f"sync already running for user {user_id}")
+ self.user_calls.append(user_id)
+ if mfa_code is not None:
+ self.mfa_calls.append((user_id, mfa_code))
+ from app.sync.states import SyncOutcome
+
+ return SyncOutcome(user_id=user_id, status="success", discovered=0, imported=0, skipped=0, failed=0)
+
+ async def sync_all_enabled(self):
+ self.all_calls += 1
+ return []
+
+
+@pytest.fixture
+def fake_sync_manager() -> FakeSyncManager:
+ return FakeSyncManager()
+
+
+@pytest.fixture
+def authenticated_client(app, client: TestClient, fake_sync_manager: FakeSyncManager) -> TestClient:
+ page = client.get("/login")
+ csrf = _extract_csrf(page.text)
+ response = client.post("/login", data={"password": "admin-secret", "csrf_token": csrf}, follow_redirects=False)
+ assert response.status_code == 303
+ app.state.sync_manager = fake_sync_manager
+ client.csrf_token = csrf
+ return client
@pytest.fixture
diff --git a/tests/web/test_operations.py b/tests/web/test_operations.py
new file mode 100644
index 0000000..5f14c5f
--- /dev/null
+++ b/tests/web/test_operations.py
@@ -0,0 +1,62 @@
+from fastapi.testclient import TestClient
+
+
+def test_manual_sync_calls_shared_manager(authenticated_client, fake_sync_manager) -> None:
+ response = authenticated_client.post(
+ "/users/1/sync",
+ data={"csrf_token": authenticated_client.csrf_token},
+ )
+ assert response.status_code == 200
+ assert fake_sync_manager.user_calls == [1]
+
+
+def test_manual_sync_reports_already_running(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 "already running" in response.text.lower()
+
+
+def test_sync_all_calls_shared_manager(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 fake_sync_manager.all_calls == 1
+
+
+def test_manual_sync_requires_admin(client: TestClient) -> None:
+ response = client.post(
+ "/users/1/sync",
+ data={"csrf_token": "whatever"},
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+ assert response.headers["location"] == "/login"
+
+
+def test_manual_sync_rejects_invalid_csrf(authenticated_client) -> None:
+ response = authenticated_client.post(
+ "/users/1/sync",
+ data={"csrf_token": "invalid-token"},
+ )
+ assert response.status_code == 403
+
+
+def test_system_page_shows_scheduler_state(app, authenticated_client) -> None:
+ class FakeScheduler:
+ def __init__(self) -> None:
+ self.last_tick = None
+ self.next_tick = None
+
+ app.state.scheduler = FakeScheduler()
+
+ response = authenticated_client.get("/system")
+ assert response.status_code == 200
+ assert "1.0.0" in response.text
+ assert "5" in response.text # sync_interval_minutes
+ assert "0" in response.text # user_count / activity_count fresh DB