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

View File

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

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

View File

@@ -4,13 +4,28 @@
{% block content %}
<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>
{% for user in users %}
{% for row in rows %}
<li>
<a href="/users/{{ user.id }}">{{ user.name }}</a>
&mdash; {{ "enabled" if user.enabled else "disabled" }}
&mdash; {{ user.health_state.value }}
<a href="/users/{{ row.id }}">{{ row.name }}</a>
&mdash; {{ "enabled" if row.enabled else "disabled" }}
&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>
{% else %}
<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
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

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