Dashboard eingefügt
This commit is contained in:
@@ -5,6 +5,8 @@ POLL_INTERVAL_SECONDS=3600
|
|||||||
DATA_DIR=/data
|
DATA_DIR=/data
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
DRY_RUN=true
|
DRY_RUN=true
|
||||||
|
DASHBOARD_HOST=0.0.0.0
|
||||||
|
DASHBOARD_PORT=8080
|
||||||
|
|
||||||
MYWHOOSH_LOGIN_URL=https://www.event.mywhoosh.com/login/
|
MYWHOOSH_LOGIN_URL=https://www.event.mywhoosh.com/login/
|
||||||
MYWHOOSH_ACTIVITY_URL=https://event.mywhoosh.com/user/activities
|
MYWHOOSH_ACTIVITY_URL=https://event.mywhoosh.com/user/activities
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -45,6 +45,14 @@ docker compose logs -f sync
|
|||||||
|
|
||||||
The default polling interval is hourly.
|
The default polling interval is hourly.
|
||||||
|
|
||||||
|
Open the local dashboard:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8080/
|
||||||
|
```
|
||||||
|
|
||||||
|
It shows the last sync check state and the last five uploaded Garmin activities.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Required values:
|
Required values:
|
||||||
@@ -61,6 +69,8 @@ POLL_INTERVAL_SECONDS=3600
|
|||||||
DATA_DIR=/data
|
DATA_DIR=/data
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
|
DASHBOARD_HOST=0.0.0.0
|
||||||
|
DASHBOARD_PORT=8080
|
||||||
MYWHOOSH_LOGIN_URL=https://www.mywhoosh.com/login/
|
MYWHOOSH_LOGIN_URL=https://www.mywhoosh.com/login/
|
||||||
MYWHOOSH_ACTIVITY_URL=https://www.mywhoosh.com/profile/
|
MYWHOOSH_ACTIVITY_URL=https://www.mywhoosh.com/profile/
|
||||||
MYWHOOSH_ACTIVITIES_BUTTON_TEXT=ACTIVITIES
|
MYWHOOSH_ACTIVITIES_BUTTON_TEXT=ACTIVITIES
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class Settings:
|
|||||||
db_path: Path
|
db_path: Path
|
||||||
log_level: str
|
log_level: str
|
||||||
dry_run: bool
|
dry_run: bool
|
||||||
|
dashboard_host: str
|
||||||
|
dashboard_port: int
|
||||||
|
|
||||||
mywhoosh_login_url: str
|
mywhoosh_login_url: str
|
||||||
mywhoosh_activity_url: str
|
mywhoosh_activity_url: str
|
||||||
@@ -106,6 +108,8 @@ class Settings:
|
|||||||
db_path=Path(os.getenv("STATE_DB", str(data_dir / "state.sqlite3"))),
|
db_path=Path(os.getenv("STATE_DB", str(data_dir / "state.sqlite3"))),
|
||||||
log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
||||||
dry_run=_bool_env("DRY_RUN", False),
|
dry_run=_bool_env("DRY_RUN", False),
|
||||||
|
dashboard_host=os.getenv("DASHBOARD_HOST", "0.0.0.0"),
|
||||||
|
dashboard_port=_int_env("DASHBOARD_PORT", 8080),
|
||||||
mywhoosh_login_url=os.getenv(
|
mywhoosh_login_url=os.getenv(
|
||||||
"MYWHOOSH_LOGIN_URL", "https://www.mywhoosh.com/login/"
|
"MYWHOOSH_LOGIN_URL", "https://www.mywhoosh.com/login/"
|
||||||
),
|
),
|
||||||
|
|||||||
369
src/mywhoosh_garmin_sync/dashboard.py
Normal file
369
src/mywhoosh_garmin_sync/dashboard.py
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from html import escape
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from .state import ActivityStore, ActivitySummary, SyncCheckSummary
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardServer:
|
||||||
|
def __init__(self, store: ActivityStore, host: str, port: int) -> None:
|
||||||
|
self.store = store
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self._server: ThreadingHTTPServer | None = None
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
handler = _handler_factory(self.store)
|
||||||
|
self._server = ThreadingHTTPServer((self.host, self.port), handler)
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._server.serve_forever,
|
||||||
|
name="dashboard-http",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Dashboard listening on http://%s:%s", self.host, self.port)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self._server is not None:
|
||||||
|
self._server.shutdown()
|
||||||
|
self._server.server_close()
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def render_dashboard(
|
||||||
|
latest_check: SyncCheckSummary | None,
|
||||||
|
recent_uploads: list[ActivitySummary],
|
||||||
|
refresh_seconds: int = 30,
|
||||||
|
) -> str:
|
||||||
|
status_label = _status_label(latest_check)
|
||||||
|
status_class = _status_class(latest_check)
|
||||||
|
message = _status_message(latest_check)
|
||||||
|
checked_at = latest_check.finished_at if latest_check else None
|
||||||
|
started_at = latest_check.started_at if latest_check else None
|
||||||
|
rows = "\n".join(_activity_row(activity) for activity in recent_uploads)
|
||||||
|
if not rows:
|
||||||
|
rows = """
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="empty">No uploaded activities recorded yet.</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
checked_markup = escape(_format_german_datetime(checked_at) or "Nie")
|
||||||
|
started_markup = escape(_format_german_datetime(started_at) or "Nie")
|
||||||
|
count_markup = _count_markup(latest_check)
|
||||||
|
error_type_markup = _error_type_markup(latest_check)
|
||||||
|
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="refresh" content="{refresh_seconds}">
|
||||||
|
<title>MyWhoosh Garmin Sync</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f5f1e8;
|
||||||
|
--ink: #17211b;
|
||||||
|
--muted: #667166;
|
||||||
|
--panel: #fffaf0;
|
||||||
|
--line: #ded5c4;
|
||||||
|
--ok: #176b3a;
|
||||||
|
--warn: #a8451a;
|
||||||
|
--wait: #78633a;
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(37, 89, 67, .18), transparent 28rem),
|
||||||
|
linear-gradient(135deg, #f7f0df 0%, #ebe4d6 100%);
|
||||||
|
}}
|
||||||
|
main {{
|
||||||
|
width: min(1080px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 0;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: clamp(2rem, 5vw, 4.5rem);
|
||||||
|
line-height: .95;
|
||||||
|
letter-spacing: -.05em;
|
||||||
|
}}
|
||||||
|
.status {{
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 250, 240, .82);
|
||||||
|
box-shadow: 0 24px 60px rgba(35, 27, 12, .12);
|
||||||
|
}}
|
||||||
|
.status-header {{
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font: 700 .78rem/1.1 ui-sans-serif, system-ui, sans-serif;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
}}
|
||||||
|
.badge.ok {{ color: var(--ok); }}
|
||||||
|
.badge.problem {{ color: var(--warn); }}
|
||||||
|
.badge.running {{ color: var(--wait); }}
|
||||||
|
.message {{
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}}
|
||||||
|
.meta {{
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font: .95rem/1.5 ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}}
|
||||||
|
section {{
|
||||||
|
margin-top: 28px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--panel);
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font: .95rem/1.45 ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 13px 10px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .78rem;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
a {{ color: #245b44; }}
|
||||||
|
.empty {{ color: var(--muted); text-align: center; }}
|
||||||
|
.path {{ color: var(--muted); overflow-wrap: anywhere; }}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
main {{ padding-top: 24px; }}
|
||||||
|
section, .status {{ padding: 18px; }}
|
||||||
|
table, thead, tbody, tr, th, td {{ display: block; }}
|
||||||
|
thead {{ display: none; }}
|
||||||
|
tr {{ border-top: 1px solid var(--line); padding: 10px 0; }}
|
||||||
|
td {{ border: 0; padding: 5px 0; }}
|
||||||
|
td::before {{
|
||||||
|
content: attr(data-label);
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>MyWhoosh Garmin Sync</h1>
|
||||||
|
<div class="status">
|
||||||
|
<div class="status-header">
|
||||||
|
<span class="badge {status_class}">{escape(status_label)}</span>
|
||||||
|
<span class="meta">Auto-refreshes every {refresh_seconds}s</span>
|
||||||
|
</div>
|
||||||
|
<p class="message">{escape(message)}</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span>Gestartet: {started_markup}</span>
|
||||||
|
<span>Beendet: {checked_markup}</span>
|
||||||
|
<span>{count_markup}</span>
|
||||||
|
{error_type_markup}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section>
|
||||||
|
<h2>Last 5 Uploaded Activities</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Uploaded</th>
|
||||||
|
<th>Garmin ID</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Converted File</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_health(latest_check: SyncCheckSummary | None) -> str:
|
||||||
|
payload = {
|
||||||
|
"status": latest_check.status if latest_check else "never_checked",
|
||||||
|
"message": latest_check.message if latest_check else None,
|
||||||
|
"error_type": latest_check.error_type if latest_check else None,
|
||||||
|
"finished_at": latest_check.finished_at if latest_check else None,
|
||||||
|
}
|
||||||
|
return json.dumps(payload, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _handler_factory(store: ActivityStore) -> type[BaseHTTPRequestHandler]:
|
||||||
|
class DashboardRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
try:
|
||||||
|
store.initialize()
|
||||||
|
if path == "/":
|
||||||
|
latest = store.get_latest_sync_check()
|
||||||
|
uploads = store.get_recent_uploaded_activities(5)
|
||||||
|
body = render_dashboard(latest, uploads)
|
||||||
|
self._send(HTTPStatus.OK, "text/html; charset=utf-8", body)
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/healthz":
|
||||||
|
body = render_health(store.get_latest_sync_check())
|
||||||
|
self._send(HTTPStatus.OK, "application/json", body)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", "Not found")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Dashboard request failed")
|
||||||
|
self._send(
|
||||||
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
"Dashboard error",
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: object) -> None:
|
||||||
|
logger.debug("Dashboard: " + fmt, *args)
|
||||||
|
|
||||||
|
def _send(self, status: HTTPStatus, content_type: str, body: str) -> None:
|
||||||
|
encoded = body.encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
return DashboardRequestHandler
|
||||||
|
|
||||||
|
|
||||||
|
def _status_label(latest_check: SyncCheckSummary | None) -> str:
|
||||||
|
if latest_check is None:
|
||||||
|
return "Never checked"
|
||||||
|
if latest_check.status == "ok":
|
||||||
|
return "OK"
|
||||||
|
if latest_check.status == "problem":
|
||||||
|
return "Problem"
|
||||||
|
return "Running"
|
||||||
|
|
||||||
|
|
||||||
|
def _status_class(latest_check: SyncCheckSummary | None) -> str:
|
||||||
|
if latest_check is None:
|
||||||
|
return "running"
|
||||||
|
if latest_check.status == "problem":
|
||||||
|
return "problem"
|
||||||
|
if latest_check.status == "ok":
|
||||||
|
return "ok"
|
||||||
|
return "running"
|
||||||
|
|
||||||
|
|
||||||
|
def _status_message(latest_check: SyncCheckSummary | None) -> str:
|
||||||
|
if latest_check is None:
|
||||||
|
return "No sync check has finished yet."
|
||||||
|
if latest_check.message:
|
||||||
|
return latest_check.message
|
||||||
|
if latest_check.status == "ok":
|
||||||
|
return "Last sync check completed successfully."
|
||||||
|
if latest_check.status == "problem":
|
||||||
|
return "Last sync check found a problem."
|
||||||
|
return "Sync check is running."
|
||||||
|
|
||||||
|
|
||||||
|
def _count_markup(latest_check: SyncCheckSummary | None) -> str:
|
||||||
|
if latest_check is None:
|
||||||
|
return "Downloaded: 0, processed: 0, failed: 0"
|
||||||
|
return escape(
|
||||||
|
f"Downloaded: {latest_check.downloaded_count}, "
|
||||||
|
f"processed: {latest_check.processed_count}, "
|
||||||
|
f"failed: {latest_check.failed_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _error_type_markup(latest_check: SyncCheckSummary | None) -> str:
|
||||||
|
if latest_check is None or not latest_check.error_type:
|
||||||
|
return ""
|
||||||
|
return f"<span>Problem type: {escape(latest_check.error_type)}</span>"
|
||||||
|
|
||||||
|
|
||||||
|
def _activity_row(activity: ActivitySummary) -> str:
|
||||||
|
title = escape(activity.title or "MyWhoosh activity")
|
||||||
|
uploaded = escape(
|
||||||
|
_format_german_datetime(activity.uploaded_at or activity.updated_at)
|
||||||
|
or activity.uploaded_at
|
||||||
|
or activity.updated_at
|
||||||
|
)
|
||||||
|
garmin_id = escape(activity.garmin_activity_id or "-")
|
||||||
|
source = _link(activity.source_url, "Open") if activity.source_url else "-"
|
||||||
|
converted = escape(activity.converted_path or "-")
|
||||||
|
return f"""
|
||||||
|
<tr>
|
||||||
|
<td data-label="Title">{title}</td>
|
||||||
|
<td data-label="Uploaded">{uploaded}</td>
|
||||||
|
<td data-label="Garmin ID">{garmin_id}</td>
|
||||||
|
<td data-label="Source">{source}</td>
|
||||||
|
<td data-label="Converted File" class="path">{converted}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _link(url: str, label: str) -> str:
|
||||||
|
safe_url = escape(url, quote=True)
|
||||||
|
return f'<a href="{safe_url}" rel="noreferrer">{escape(label)}</a>'
|
||||||
|
|
||||||
|
|
||||||
|
def _format_german_datetime(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
|
||||||
|
return parsed.strftime("%d.%m.%Y, %H:%M Uhr")
|
||||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
|
from .dashboard import DashboardServer
|
||||||
from .fit_device import GarminDevice, convert_fit_device
|
from .fit_device import GarminDevice, convert_fit_device
|
||||||
from .garmin import GarminUploadBlocked, GarminUploader
|
from .garmin import GarminUploadBlocked, GarminUploader
|
||||||
from .models import DownloadedActivity
|
from .models import DownloadedActivity
|
||||||
@@ -15,6 +17,13 @@ from .state import ActivityStore, sha256_file
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProcessingResult:
|
||||||
|
status: str
|
||||||
|
error_type: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SyncService:
|
class SyncService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -36,7 +45,14 @@ class SyncService:
|
|||||||
|
|
||||||
async def serve(self) -> None:
|
async def serve(self) -> None:
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
|
dashboard = DashboardServer(
|
||||||
|
self.store,
|
||||||
|
host=self.settings.dashboard_host,
|
||||||
|
port=self.settings.dashboard_port,
|
||||||
|
)
|
||||||
|
dashboard.start()
|
||||||
|
|
||||||
|
try:
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
@@ -61,18 +77,70 @@ class SyncService:
|
|||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
continue
|
continue
|
||||||
|
finally:
|
||||||
|
dashboard.stop()
|
||||||
|
|
||||||
async def run_once(self) -> None:
|
async def run_once(self) -> None:
|
||||||
self.store.initialize()
|
self.store.initialize()
|
||||||
|
check_id = self.store.begin_sync_check()
|
||||||
|
downloads: list[DownloadedActivity] = []
|
||||||
|
processed_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
first_error_type: str | None = None
|
||||||
|
first_error_message: str | None = None
|
||||||
|
try:
|
||||||
downloads = await self.crawler.download_new_activities(
|
downloads = await self.crawler.download_new_activities(
|
||||||
self.store.is_terminal_source
|
self.store.is_terminal_source
|
||||||
)
|
)
|
||||||
logger.info("Downloaded %d candidate activities", len(downloads))
|
logger.info("Downloaded %d candidate activities", len(downloads))
|
||||||
|
|
||||||
for activity in downloads:
|
for activity in downloads:
|
||||||
await asyncio.to_thread(self._process_activity, activity)
|
result = await asyncio.to_thread(self._process_activity, activity)
|
||||||
|
processed_count += 1
|
||||||
|
if result.status == "failed":
|
||||||
|
failed_count += 1
|
||||||
|
first_error_type = first_error_type or result.error_type
|
||||||
|
first_error_message = first_error_message or result.message
|
||||||
|
|
||||||
def _process_activity(self, activity: DownloadedActivity) -> None:
|
if failed_count:
|
||||||
|
message = (
|
||||||
|
f"{failed_count} of {processed_count} downloaded activities failed."
|
||||||
|
)
|
||||||
|
if first_error_message:
|
||||||
|
message = f"{message} First error: {first_error_message}"
|
||||||
|
self.store.finish_sync_check(
|
||||||
|
check_id,
|
||||||
|
status="problem",
|
||||||
|
message=message,
|
||||||
|
error_type=first_error_type or "sync_error",
|
||||||
|
downloaded_count=len(downloads),
|
||||||
|
processed_count=processed_count,
|
||||||
|
failed_count=failed_count,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.store.finish_sync_check(
|
||||||
|
check_id,
|
||||||
|
status="ok",
|
||||||
|
message=_ok_message(len(downloads), processed_count),
|
||||||
|
error_type=None,
|
||||||
|
downloaded_count=len(downloads),
|
||||||
|
processed_count=processed_count,
|
||||||
|
failed_count=0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.store.finish_sync_check(
|
||||||
|
check_id,
|
||||||
|
status="problem",
|
||||||
|
message=str(exc),
|
||||||
|
error_type=_classify_sync_exception(exc),
|
||||||
|
downloaded_count=len(downloads),
|
||||||
|
processed_count=processed_count,
|
||||||
|
failed_count=failed_count,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _process_activity(self, activity: DownloadedActivity) -> ProcessingResult:
|
||||||
try:
|
try:
|
||||||
raw_hash = sha256_file(activity.raw_path)
|
raw_hash = sha256_file(activity.raw_path)
|
||||||
self.store.record_downloaded(
|
self.store.record_downloaded(
|
||||||
@@ -88,7 +156,7 @@ class SyncService:
|
|||||||
activity.source_ref,
|
activity.source_ref,
|
||||||
"Raw file hash was already uploaded from another source.",
|
"Raw file hash was already uploaded from another source.",
|
||||||
)
|
)
|
||||||
return
|
return ProcessingResult("duplicate")
|
||||||
|
|
||||||
converted_path = self._converted_path(activity)
|
converted_path = self._converted_path(activity)
|
||||||
result = convert_fit_device(activity.raw_path, converted_path, self.device)
|
result = convert_fit_device(activity.raw_path, converted_path, self.device)
|
||||||
@@ -105,29 +173,67 @@ class SyncService:
|
|||||||
activity.source_ref,
|
activity.source_ref,
|
||||||
"Converted file hash was already uploaded from another source.",
|
"Converted file hash was already uploaded from another source.",
|
||||||
)
|
)
|
||||||
return
|
return ProcessingResult("duplicate")
|
||||||
|
|
||||||
if self.settings.dry_run:
|
if self.settings.dry_run:
|
||||||
logger.info("Dry run enabled; not uploading %s", converted_path)
|
logger.info("Dry run enabled; not uploading %s", converted_path)
|
||||||
return
|
return ProcessingResult("converted")
|
||||||
|
|
||||||
upload = self.uploader.upload(converted_path)
|
upload = self.uploader.upload(converted_path)
|
||||||
if upload.duplicate:
|
if upload.duplicate:
|
||||||
self.store.mark_duplicate(
|
self.store.mark_duplicate(
|
||||||
activity.source_ref, "Garmin reported a duplicate activity."
|
activity.source_ref, "Garmin reported a duplicate activity."
|
||||||
)
|
)
|
||||||
|
return ProcessingResult("duplicate")
|
||||||
else:
|
else:
|
||||||
self.store.mark_uploaded(
|
self.store.mark_uploaded(
|
||||||
activity.source_ref,
|
activity.source_ref,
|
||||||
garmin_activity_id=upload.garmin_activity_id,
|
garmin_activity_id=upload.garmin_activity_id,
|
||||||
)
|
)
|
||||||
|
return ProcessingResult("uploaded")
|
||||||
except GarminUploadBlocked as exc:
|
except GarminUploadBlocked as exc:
|
||||||
logger.error("Upload blocked: %s", exc)
|
logger.error("Upload blocked: %s", exc)
|
||||||
self.store.mark_failed(activity.source_ref, str(exc))
|
self.store.mark_failed(activity.source_ref, str(exc))
|
||||||
|
return ProcessingResult("failed", "garmin_login", str(exc))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Failed processing %s", activity.raw_path)
|
logger.exception("Failed processing %s", activity.raw_path)
|
||||||
self.store.mark_failed(activity.source_ref, str(exc))
|
self.store.mark_failed(activity.source_ref, str(exc))
|
||||||
|
return ProcessingResult(
|
||||||
|
"failed",
|
||||||
|
_classify_activity_exception(exc),
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
def _converted_path(self, activity: DownloadedActivity) -> Path:
|
def _converted_path(self, activity: DownloadedActivity) -> Path:
|
||||||
return self.settings.converted_dir / activity.raw_path.name
|
return self.settings.converted_dir / activity.raw_path.name
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_message(downloaded_count: int, processed_count: int) -> str:
|
||||||
|
if downloaded_count == 0:
|
||||||
|
return "OK: no new activities found."
|
||||||
|
if processed_count == 1:
|
||||||
|
return "OK: processed 1 activity."
|
||||||
|
return f"OK: processed {processed_count} activities."
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_sync_exception(exc: Exception) -> str:
|
||||||
|
if isinstance(exc, GarminUploadBlocked):
|
||||||
|
return "garmin_login"
|
||||||
|
|
||||||
|
text = str(exc).lower()
|
||||||
|
if any(token in text for token in ("captcha", "bot challenge", "challenge", "cloudflare")):
|
||||||
|
return "mywhoosh_challenge"
|
||||||
|
if any(token in text for token in ("mywhoosh", "login", "credential", "mfa", "auth")):
|
||||||
|
return "mywhoosh_login"
|
||||||
|
if "garmin" in text:
|
||||||
|
return "garmin_upload"
|
||||||
|
return "sync_error"
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_activity_exception(exc: Exception) -> str:
|
||||||
|
text = str(exc).lower()
|
||||||
|
if any(token in text for token in ("mfa", "login", "auth", "credential")):
|
||||||
|
return "garmin_login"
|
||||||
|
if any(token in text for token in ("garmin", "upload", "connect")):
|
||||||
|
return "garmin_upload"
|
||||||
|
return "sync_error"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
@@ -22,6 +23,30 @@ def sha256_file(path: Path) -> str:
|
|||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActivitySummary:
|
||||||
|
source_ref: str
|
||||||
|
title: str | None
|
||||||
|
source_url: str | None
|
||||||
|
converted_path: str | None
|
||||||
|
garmin_activity_id: str | None
|
||||||
|
uploaded_at: str | None
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SyncCheckSummary:
|
||||||
|
id: int
|
||||||
|
started_at: str
|
||||||
|
finished_at: str | None
|
||||||
|
status: str
|
||||||
|
message: str | None
|
||||||
|
error_type: str | None
|
||||||
|
downloaded_count: int
|
||||||
|
processed_count: int
|
||||||
|
failed_count: int
|
||||||
|
|
||||||
|
|
||||||
class ActivityStore:
|
class ActivityStore:
|
||||||
def __init__(self, db_path: Path) -> None:
|
def __init__(self, db_path: Path) -> None:
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
@@ -59,6 +84,124 @@ class ActivityStore:
|
|||||||
ON activities(converted_sha256)
|
ON activities(converted_sha256)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_checks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
error_type TEXT,
|
||||||
|
downloaded_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
processed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_checks_started_at
|
||||||
|
ON sync_checks(started_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def begin_sync_check(self) -> int:
|
||||||
|
with self._connect() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sync_checks (started_at, status)
|
||||||
|
VALUES (?, 'running')
|
||||||
|
""",
|
||||||
|
(utc_now(),),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def finish_sync_check(
|
||||||
|
self,
|
||||||
|
check_id: int,
|
||||||
|
status: str,
|
||||||
|
message: str | None,
|
||||||
|
error_type: str | None,
|
||||||
|
downloaded_count: int,
|
||||||
|
processed_count: int,
|
||||||
|
failed_count: int,
|
||||||
|
) -> None:
|
||||||
|
self._execute(
|
||||||
|
"""
|
||||||
|
UPDATE sync_checks
|
||||||
|
SET finished_at = ?,
|
||||||
|
status = ?,
|
||||||
|
message = ?,
|
||||||
|
error_type = ?,
|
||||||
|
downloaded_count = ?,
|
||||||
|
processed_count = ?,
|
||||||
|
failed_count = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
utc_now(),
|
||||||
|
status,
|
||||||
|
message[:1000] if message else None,
|
||||||
|
error_type,
|
||||||
|
downloaded_count,
|
||||||
|
processed_count,
|
||||||
|
failed_count,
|
||||||
|
check_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_latest_sync_check(self) -> SyncCheckSummary | None:
|
||||||
|
row = self._fetch_one(
|
||||||
|
"""
|
||||||
|
SELECT id, started_at, finished_at, status, message, error_type,
|
||||||
|
downloaded_count, processed_count, failed_count
|
||||||
|
FROM sync_checks
|
||||||
|
ORDER BY started_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return SyncCheckSummary(
|
||||||
|
id=row["id"],
|
||||||
|
started_at=row["started_at"],
|
||||||
|
finished_at=row["finished_at"],
|
||||||
|
status=row["status"],
|
||||||
|
message=row["message"],
|
||||||
|
error_type=row["error_type"],
|
||||||
|
downloaded_count=row["downloaded_count"],
|
||||||
|
processed_count=row["processed_count"],
|
||||||
|
failed_count=row["failed_count"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_recent_uploaded_activities(self, limit: int = 5) -> list[ActivitySummary]:
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_ref, title, source_url, converted_path,
|
||||||
|
garmin_activity_id, uploaded_at, updated_at
|
||||||
|
FROM activities
|
||||||
|
WHERE status = 'uploaded'
|
||||||
|
ORDER BY uploaded_at DESC, updated_at DESC, source_ref DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
ActivitySummary(
|
||||||
|
source_ref=row["source_ref"],
|
||||||
|
title=row["title"],
|
||||||
|
source_url=row["source_url"],
|
||||||
|
converted_path=row["converted_path"],
|
||||||
|
garmin_activity_id=row["garmin_activity_id"],
|
||||||
|
uploaded_at=row["uploaded_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
def is_terminal_source(self, source_ref: str) -> bool:
|
def is_terminal_source(self, source_ref: str) -> bool:
|
||||||
row = self._fetch_one(
|
row = self._fetch_one(
|
||||||
@@ -216,4 +359,3 @@ class ActivityStore:
|
|||||||
) -> sqlite3.Row | None:
|
) -> sqlite3.Row | None:
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
return conn.execute(sql, params).fetchone()
|
return conn.execute(sql, params).fetchone()
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ from mywhoosh_garmin_sync.config import Settings
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def settings(tmp_path: Path) -> Settings:
|
def settings(tmp_path: Path) -> Settings:
|
||||||
return Settings(
|
return Settings(
|
||||||
mywhoosh_email="whoosh@example.com",
|
|
||||||
mywhoosh_password="whoosh-pass",
|
|
||||||
garmin_email="garmin@example.com",
|
garmin_email="garmin@example.com",
|
||||||
garmin_password="garmin-pass",
|
garmin_password="garmin-pass",
|
||||||
poll_interval_seconds=3600,
|
poll_interval_seconds=3600,
|
||||||
@@ -22,6 +20,8 @@ def settings(tmp_path: Path) -> Settings:
|
|||||||
db_path=tmp_path / "state.sqlite3",
|
db_path=tmp_path / "state.sqlite3",
|
||||||
log_level="INFO",
|
log_level="INFO",
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
|
dashboard_host="127.0.0.1",
|
||||||
|
dashboard_port=8080,
|
||||||
mywhoosh_login_url="https://www.mywhoosh.com/login/",
|
mywhoosh_login_url="https://www.mywhoosh.com/login/",
|
||||||
mywhoosh_activity_url="https://www.mywhoosh.com/profile/",
|
mywhoosh_activity_url="https://www.mywhoosh.com/profile/",
|
||||||
mywhoosh_headless=True,
|
mywhoosh_headless=True,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ def test_settings_from_env(monkeypatch, tmp_path):
|
|||||||
assert settings.raw_dir == tmp_path / "raw"
|
assert settings.raw_dir == tmp_path / "raw"
|
||||||
assert settings.dry_run is True
|
assert settings.dry_run is True
|
||||||
assert settings.poll_interval_seconds == 123
|
assert settings.poll_interval_seconds == 123
|
||||||
|
assert settings.dashboard_host == "0.0.0.0"
|
||||||
|
assert settings.dashboard_port == 8080
|
||||||
assert settings.target_garmin_product_id == 3578
|
assert settings.target_garmin_product_id == 3578
|
||||||
assert settings.target_garmin_serial_number == 123456
|
assert settings.target_garmin_serial_number == 123456
|
||||||
assert settings.mywhoosh_manual_login_wait_seconds == 900
|
assert settings.mywhoosh_manual_login_wait_seconds == 900
|
||||||
|
|||||||
66
tests/test_dashboard.py
Normal file
66
tests/test_dashboard.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from mywhoosh_garmin_sync.dashboard import render_dashboard, render_health
|
||||||
|
from mywhoosh_garmin_sync.state import ActivitySummary, SyncCheckSummary
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_renders_latest_check_and_upload():
|
||||||
|
html = render_dashboard(
|
||||||
|
SyncCheckSummary(
|
||||||
|
id=1,
|
||||||
|
started_at="2026-05-07T10:00:00+00:00",
|
||||||
|
finished_at="2026-05-07T10:01:00+00:00",
|
||||||
|
status="ok",
|
||||||
|
message="OK: processed 1 activity.",
|
||||||
|
error_type=None,
|
||||||
|
downloaded_count=1,
|
||||||
|
processed_count=1,
|
||||||
|
failed_count=0,
|
||||||
|
),
|
||||||
|
[
|
||||||
|
ActivitySummary(
|
||||||
|
source_ref="source-1",
|
||||||
|
title="Morning Ride",
|
||||||
|
source_url="https://example.test/ride.fit",
|
||||||
|
converted_path="/data/converted/ride.fit",
|
||||||
|
garmin_activity_id="12345",
|
||||||
|
uploaded_at="2026-05-07T10:01:00+00:00",
|
||||||
|
updated_at="2026-05-07T10:01:00+00:00",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "OK: processed 1 activity." in html
|
||||||
|
assert "Morning Ride" in html
|
||||||
|
assert "12345" in html
|
||||||
|
assert "07.05.2026, 10:01 Uhr" in html
|
||||||
|
assert "/data/converted/ride.fit" in html
|
||||||
|
assert '<meta http-equiv="refresh" content="30">' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_renders_empty_state_before_first_check():
|
||||||
|
html = render_dashboard(None, [])
|
||||||
|
|
||||||
|
assert "Never checked" in html
|
||||||
|
assert "No sync check has finished yet." in html
|
||||||
|
assert "No uploaded activities recorded yet." in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_renders_latest_status():
|
||||||
|
body = render_health(
|
||||||
|
SyncCheckSummary(
|
||||||
|
id=1,
|
||||||
|
started_at="2026-05-07T10:00:00+00:00",
|
||||||
|
finished_at="2026-05-07T10:01:00+00:00",
|
||||||
|
status="problem",
|
||||||
|
message="Garmin requested MFA",
|
||||||
|
error_type="garmin_login",
|
||||||
|
downloaded_count=1,
|
||||||
|
processed_count=1,
|
||||||
|
failed_count=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert body == (
|
||||||
|
'{"status":"problem","message":"Garmin requested MFA",'
|
||||||
|
'"error_type":"garmin_login",'
|
||||||
|
'"finished_at":"2026-05-07T10:01:00+00:00"}'
|
||||||
|
)
|
||||||
95
tests/test_service.py
Normal file
95
tests/test_service.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mywhoosh_garmin_sync.models import DownloadedActivity
|
||||||
|
from mywhoosh_garmin_sync.service import ProcessingResult, SyncService
|
||||||
|
from mywhoosh_garmin_sync.state import ActivityStore
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCrawler:
|
||||||
|
def __init__(self, downloads=None, exc: Exception | None = None):
|
||||||
|
self.downloads = downloads or []
|
||||||
|
self.exc = exc
|
||||||
|
|
||||||
|
async def download_new_activities(self, should_skip_source):
|
||||||
|
if self.exc is not None:
|
||||||
|
raise self.exc
|
||||||
|
return self.downloads
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUploader:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_records_ok_check_when_no_downloads(settings):
|
||||||
|
store = ActivityStore(settings.db_path)
|
||||||
|
service = SyncService(
|
||||||
|
settings,
|
||||||
|
crawler=FakeCrawler(),
|
||||||
|
uploader=FakeUploader(),
|
||||||
|
store=store,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(service.run_once())
|
||||||
|
|
||||||
|
latest = store.get_latest_sync_check()
|
||||||
|
assert latest is not None
|
||||||
|
assert latest.status == "ok"
|
||||||
|
assert latest.message == "OK: no new activities found."
|
||||||
|
assert latest.downloaded_count == 0
|
||||||
|
assert latest.failed_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_records_mywhoosh_problem_before_downloads(settings):
|
||||||
|
store = ActivityStore(settings.db_path)
|
||||||
|
service = SyncService(
|
||||||
|
settings,
|
||||||
|
crawler=FakeCrawler(exc=RuntimeError("MyWhoosh login did not complete")),
|
||||||
|
uploader=FakeUploader(),
|
||||||
|
store=store,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
asyncio.run(service.run_once())
|
||||||
|
|
||||||
|
latest = store.get_latest_sync_check()
|
||||||
|
assert latest is not None
|
||||||
|
assert latest.status == "problem"
|
||||||
|
assert latest.error_type == "mywhoosh_login"
|
||||||
|
assert latest.message == "MyWhoosh login did not complete"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_records_activity_processing_problem(settings, tmp_path: Path):
|
||||||
|
raw_path = tmp_path / "activity.fit"
|
||||||
|
raw_path.write_bytes(b"not used")
|
||||||
|
activity = DownloadedActivity(
|
||||||
|
source_ref="source-1",
|
||||||
|
title="Ride",
|
||||||
|
url="https://example.test/ride.fit",
|
||||||
|
raw_path=raw_path,
|
||||||
|
)
|
||||||
|
store = ActivityStore(settings.db_path)
|
||||||
|
service = SyncService(
|
||||||
|
settings,
|
||||||
|
crawler=FakeCrawler(downloads=[activity]),
|
||||||
|
uploader=FakeUploader(),
|
||||||
|
store=store,
|
||||||
|
)
|
||||||
|
service._process_activity = lambda activity: ProcessingResult(
|
||||||
|
"failed",
|
||||||
|
"garmin_login",
|
||||||
|
"Garmin requested MFA but GARMIN_MFA_CODE is not set.",
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(service.run_once())
|
||||||
|
|
||||||
|
latest = store.get_latest_sync_check()
|
||||||
|
assert latest is not None
|
||||||
|
assert latest.status == "problem"
|
||||||
|
assert latest.error_type == "garmin_login"
|
||||||
|
assert latest.downloaded_count == 1
|
||||||
|
assert latest.processed_count == 1
|
||||||
|
assert latest.failed_count == 1
|
||||||
|
assert "1 of 1 downloaded activities failed" in latest.message
|
||||||
@@ -42,3 +42,62 @@ def test_failed_activity_can_be_retried(tmp_path: Path):
|
|||||||
assert store.get_status("source-1") == "failed"
|
assert store.get_status("source-1") == "failed"
|
||||||
assert store.is_terminal_source("source-1") is False
|
assert store.is_terminal_source("source-1") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_check_summary_round_trip(tmp_path: Path):
|
||||||
|
store = ActivityStore(tmp_path / "state.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
|
||||||
|
check_id = store.begin_sync_check()
|
||||||
|
store.finish_sync_check(
|
||||||
|
check_id=check_id,
|
||||||
|
status="problem",
|
||||||
|
message="MyWhoosh login did not complete",
|
||||||
|
error_type="mywhoosh_login",
|
||||||
|
downloaded_count=2,
|
||||||
|
processed_count=1,
|
||||||
|
failed_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
latest = store.get_latest_sync_check()
|
||||||
|
|
||||||
|
assert latest is not None
|
||||||
|
assert latest.id == check_id
|
||||||
|
assert latest.status == "problem"
|
||||||
|
assert latest.message == "MyWhoosh login did not complete"
|
||||||
|
assert latest.error_type == "mywhoosh_login"
|
||||||
|
assert latest.downloaded_count == 2
|
||||||
|
assert latest.processed_count == 1
|
||||||
|
assert latest.failed_count == 1
|
||||||
|
assert latest.finished_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_uploaded_activities_are_limited_and_ordered(tmp_path: Path):
|
||||||
|
store = ActivityStore(tmp_path / "state.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
|
||||||
|
for index in range(6):
|
||||||
|
source_ref = f"source-{index}"
|
||||||
|
store.record_downloaded(
|
||||||
|
source_ref=source_ref,
|
||||||
|
title=f"Ride {index}",
|
||||||
|
source_url=f"https://example.test/{index}",
|
||||||
|
raw_path=tmp_path / f"raw-{index}.fit",
|
||||||
|
raw_sha256=f"raw-hash-{index}",
|
||||||
|
)
|
||||||
|
store.mark_converted(
|
||||||
|
source_ref=source_ref,
|
||||||
|
converted_path=tmp_path / f"converted-{index}.fit",
|
||||||
|
converted_sha256=f"converted-hash-{index}",
|
||||||
|
patched_field_count=index,
|
||||||
|
)
|
||||||
|
store.mark_uploaded(source_ref, str(index))
|
||||||
|
|
||||||
|
recent = store.get_recent_uploaded_activities(5)
|
||||||
|
|
||||||
|
assert [activity.title for activity in recent] == [
|
||||||
|
"Ride 5",
|
||||||
|
"Ride 4",
|
||||||
|
"Ride 3",
|
||||||
|
"Ride 2",
|
||||||
|
"Ride 1",
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user