feat: add operational sync controls

This commit is contained in:
Bastian Wagner
2026-08-15 16:24:11 +02:00
parent fd50bbbab7
commit c2f13611b9
9 changed files with 305 additions and 11 deletions

View File

@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from sqlalchemy import and_, or_, select 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 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: class UserRepository:
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
self.session = session self.session = session
@@ -32,6 +45,28 @@ class UserRepository:
self.session.commit() self.session.commit()
return user 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: class ActivityRepository:
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:

View File

@@ -11,6 +11,7 @@ from app.mywhoosh.client import MyWhooshClient
from app.security.credentials import CredentialCipher from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager from app.sync.manager import SyncManager
from app.sync.scheduler import SyncScheduler from app.sync.scheduler import SyncScheduler
from app.web.operations import router as operations_router
from app.web.routes import router as web_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, https_only=resolved.session_https_only,
) )
app.include_router(web_router) app.include_router(web_router)
app.include_router(operations_router)
@app.get("/healthz") @app.get("/healthz")
def healthz() -> dict[str, str]: def healthz() -> dict[str, str]:

78
app/web/operations.py Normal file
View File

@@ -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,
})

View File

@@ -62,11 +62,11 @@ def login(
def dashboard(request: Request): def dashboard(request: Request):
require_admin(request) require_admin(request)
with request.app.state.session_factory() as session: with request.app.state.session_factory() as session:
users = UserRepository(session).list_all() rows = UserRepository(session).dashboard_rows()
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"dashboard.html", "dashboard.html",
{"users": users, "csrf_token": ensure_csrf_token(request)}, {"rows": rows, "csrf_token": ensure_csrf_token(request)},
) )

View File

@@ -4,13 +4,28 @@
{% block content %} {% block content %}
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p><a href="/users/new">Add user</a></p> <p><a href="/users/new">Add user</a> | <a href="/system">System</a></p>
<form method="post" action="/sync-all">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>
<ul> <ul>
{% for user in users %} {% for row in rows %}
<li> <li>
<a href="/users/{{ user.id }}">{{ user.name }}</a> <a href="/users/{{ row.id }}">{{ row.name }}</a>
&mdash; {{ "enabled" if user.enabled else "disabled" }} &mdash; {{ "enabled" if row.enabled else "disabled" }}
&mdash; {{ user.health_state.value }} &mdash; {{ row.health_state }}
&mdash; last sync: {{ row.last_sync_at or "-" }}
&mdash; last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})
{% if row.action_reason == "garmin_mfa_required" %}
<span class="action-required">Garmin MFA required — <a href="/users/{{ row.id }}">resolve</a></span>
{% endif %}
<form method="post" action="/users/{{ row.id }}/sync" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync now</button>
</form>
</li> </li>
{% else %} {% else %}
<li>No users yet.</li> <li>No users yet.</li>

View File

@@ -0,0 +1,17 @@
<ul>
{% for outcome in outcomes %}
<li>
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 %}
&mdash; {{ outcome.message }}
{% endif %}
</li>
{% else %}
<li>No outcomes.</li>
{% endfor %}
</ul>

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}System - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>System</h1>
<p><a href="/">Back to dashboard</a></p>
<dl>
<dt>Application version</dt>
<dd>{{ app_version }}</dd>
<dt>Sync interval (minutes)</dt>
<dd>{{ sync_interval_minutes }}</dd>
<dt>Last scheduler tick</dt>
<dd>{{ last_tick or "-" }}</dd>
<dt>Next scheduler tick</dt>
<dd>{{ next_tick or "-" }}</dd>
<dt>User count</dt>
<dd>{{ user_count }}</dd>
<dt>Activity count</dt>
<dd>{{ activity_count }}</dd>
</dl>
<form method="post" action="/sync-all">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>
{% endblock %}

View File

@@ -45,7 +45,7 @@ def sync_run_repository(db_session: Session) -> SyncRunRepository:
@pytest.fixture @pytest.fixture
def client(tmp_path: Path) -> TestClient: def app(tmp_path: Path):
settings = Settings( settings = Settings(
ADMIN_PASSWORD="admin-secret", ADMIN_PASSWORD="admin-secret",
SECRET_KEY="0123456789abcdef0123456789abcdef", SECRET_KEY="0123456789abcdef0123456789abcdef",
@@ -54,11 +54,63 @@ def client(tmp_path: Path) -> TestClient:
DATABASE_URL=f"sqlite:///{tmp_path / 'app.db'}", DATABASE_URL=f"sqlite:///{tmp_path / 'app.db'}",
SYNC_INTERVAL_MINUTES=5, SYNC_INTERVAL_MINUTES=5,
) )
app = create_app(settings) application = create_app(settings)
try: try:
yield TestClient(app) yield application
finally: 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 @pytest.fixture

View File

@@ -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