Compare commits
33 Commits
b48008c16e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a23147dc8 | ||
|
|
450e4e935f | ||
|
|
13864a77f7 | ||
|
|
eb97374578 | ||
|
|
8d73dea7dd | ||
|
|
cc69b3ebb6 | ||
|
|
59b39f10cb | ||
|
|
f70f511907 | ||
|
|
a0890126bc | ||
|
|
9df6a619d2 | ||
|
|
a9a46e949f | ||
|
|
aa9e289185 | ||
|
|
b6d4be97c1 | ||
|
|
a74ab95f3f | ||
|
|
5d75aa328d | ||
|
|
722570c9d3 | ||
|
|
ff5dab6f0d | ||
|
|
f7b04337ce | ||
|
|
adfe14dfa8 | ||
|
|
420d089760 | ||
|
|
2aba1265af | ||
|
|
7c9e19ba0b | ||
|
|
85b0d861b4 | ||
|
|
990a55af14 | ||
|
|
aed9d6bb48 | ||
|
|
6657124983 | ||
|
|
c2f13611b9 | ||
|
|
fd50bbbab7 | ||
|
|
1d414d6298 | ||
|
|
4aaf490bc5 | ||
|
|
c4e986e3f8 | ||
|
|
1d5bbdb2a2 | ||
|
|
2f65c0178c |
@@ -7,3 +7,12 @@ DATA_DIR=/data
|
||||
DATABASE_URL=sqlite:////data/app.db
|
||||
# Set to true only when TLS terminates in front of this service.
|
||||
SESSION_HTTPS_ONLY=false
|
||||
# Optional: SMTP settings for "email me when action is required" user
|
||||
# notifications. Leave SMTP_HOST unset to disable sending (notifications are
|
||||
# silently skipped rather than failing a sync run).
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_ADDRESS=
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
@@ -7,4 +7,8 @@ COPY app /app/app
|
||||
RUN mkdir -p /data && chmod 700 /data
|
||||
ENV DATA_DIR=/data
|
||||
EXPOSE 8080
|
||||
# Single-process assumption: per-user sync locking and the in-process
|
||||
# scheduler both live in this one worker's memory. Do not scale this to
|
||||
# multiple uvicorn workers or container replicas without adding a
|
||||
# cross-process lock -- otherwise duplicate imports become possible.
|
||||
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8080"]
|
||||
|
||||
36
app/auth/account.py
Normal file
36
app/auth/account.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.admin import password_matches
|
||||
from app.db.models import SyncUser
|
||||
from app.db.repositories import UserRepository
|
||||
from app.security.credentials import CredentialCipher
|
||||
|
||||
|
||||
def authenticate_self_service(
|
||||
session: Session, cipher: CredentialCipher, email: str, password: str
|
||||
) -> SyncUser | None:
|
||||
"""Matches submitted email/password against any user's stored MyWhoosh OR
|
||||
Garmin credentials -- decrypted and compared locally rather than
|
||||
verified against the live MyWhoosh/Garmin APIs, so logging into this app
|
||||
never opens a redundant upstream session (which, for MyWhoosh, would
|
||||
itself trigger the "already logged in from another device" conflict)."""
|
||||
submitted_email = email.strip()
|
||||
if not submitted_email or not password:
|
||||
return None
|
||||
for user in UserRepository(session).list_all():
|
||||
if _credential_matches(cipher, user.mywhoosh_email_enc, user.mywhoosh_password_enc, submitted_email, password):
|
||||
return user
|
||||
if _credential_matches(cipher, user.garmin_email_enc, user.garmin_password_enc, submitted_email, password):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
def _credential_matches(
|
||||
cipher: CredentialCipher, email_enc: str, password_enc: str, submitted_email: str, submitted_password: str
|
||||
) -> bool:
|
||||
try:
|
||||
stored_email = cipher.decrypt(email_enc)
|
||||
stored_password = cipher.decrypt(password_enc)
|
||||
except ValueError:
|
||||
return False
|
||||
return stored_email == submitted_email and password_matches(submitted_password, stored_password)
|
||||
@@ -10,3 +10,10 @@ def password_matches(submitted: str, configured: str) -> bool:
|
||||
def require_admin(request: Request) -> None:
|
||||
if request.session.get("admin_authenticated") is not True:
|
||||
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
|
||||
|
||||
|
||||
def require_self_service(request: Request) -> int:
|
||||
user_id = request.session.get("self_service_user_id")
|
||||
if not isinstance(user_id, int):
|
||||
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/account-login"})
|
||||
return user_id
|
||||
|
||||
@@ -15,6 +15,12 @@ class Settings(BaseSettings):
|
||||
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
||||
sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES")
|
||||
session_https_only: bool = Field(default=False, alias="SESSION_HTTPS_ONLY")
|
||||
smtp_host: str | None = Field(default=None, alias="SMTP_HOST")
|
||||
smtp_port: int = Field(default=587, alias="SMTP_PORT")
|
||||
smtp_username: str | None = Field(default=None, alias="SMTP_USERNAME")
|
||||
smtp_password: str | None = Field(default=None, alias="SMTP_PASSWORD")
|
||||
smtp_from_address: str | None = Field(default=None, alias="SMTP_FROM_ADDRESS")
|
||||
smtp_use_tls: bool = Field(default=True, alias="SMTP_USE_TLS")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def derive_paths(self) -> "Settings":
|
||||
|
||||
@@ -53,6 +53,8 @@ class SyncUser(Base):
|
||||
mywhoosh_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
garmin_email_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
garmin_password_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
notify_email_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
notification_email: Mapped[str | None] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
@@ -77,6 +79,30 @@ class Activity(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class SystemLogEntry(Base):
|
||||
__tablename__ = "system_log_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
source: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("sync_users.id", ondelete="SET NULL"))
|
||||
|
||||
|
||||
class SchedulerSettings(Base):
|
||||
"""Singleton row (id is always 1) holding the admin-configurable day/night
|
||||
sync interval -- editable at runtime via the UI, unlike SYNC_INTERVAL_MINUTES
|
||||
which only seeds this row's initial values on first startup."""
|
||||
|
||||
__tablename__ = "scheduler_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
day_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=6)
|
||||
night_start_hour: Mapped[int] = mapped_column(Integer, nullable=False, default=22)
|
||||
day_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||
night_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||
|
||||
|
||||
class SyncRun(Base):
|
||||
__tablename__ = "sync_runs"
|
||||
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Activity, ActivityStatus, SyncUser
|
||||
from app.db.models import (
|
||||
Activity,
|
||||
ActivityStatus,
|
||||
HealthState,
|
||||
SchedulerSettings,
|
||||
SyncRun,
|
||||
SyncRunStatus,
|
||||
SystemLogEntry,
|
||||
SyncUser,
|
||||
utcnow,
|
||||
)
|
||||
|
||||
_SCHEDULER_SETTINGS_ID = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardSummary:
|
||||
rider_total: int
|
||||
rider_enabled: int
|
||||
imported_recent: int
|
||||
success_rate_recent: float | None
|
||||
action_required_count: int
|
||||
|
||||
|
||||
@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:
|
||||
@@ -32,11 +66,80 @@ class UserRepository:
|
||||
self.session.commit()
|
||||
return user
|
||||
|
||||
def dashboard_rows(self) -> list[UserDashboardRow]:
|
||||
return [self._build_dashboard_row(user) for user in self.list_all()]
|
||||
|
||||
def dashboard_row(self, user_id: int) -> UserDashboardRow | None:
|
||||
user = self.get(user_id)
|
||||
if user is None:
|
||||
return None
|
||||
return self._build_dashboard_row(user)
|
||||
|
||||
def _build_dashboard_row(self, user: SyncUser) -> UserDashboardRow:
|
||||
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)
|
||||
)
|
||||
return 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,
|
||||
)
|
||||
|
||||
def dashboard_summary(self, *, since: datetime) -> DashboardSummary:
|
||||
rider_total = self.session.scalar(select(func.count()).select_from(SyncUser)) or 0
|
||||
rider_enabled = self.session.scalar(
|
||||
select(func.count()).select_from(SyncUser).where(SyncUser.enabled.is_(True))
|
||||
) or 0
|
||||
action_required_count = self.session.scalar(
|
||||
select(func.count()).select_from(SyncUser).where(SyncUser.health_state == HealthState.ACTION_REQUIRED)
|
||||
) or 0
|
||||
imported_recent = self.session.scalar(
|
||||
select(func.coalesce(func.sum(SyncRun.imported_count), 0)).where(SyncRun.started_at >= since)
|
||||
) or 0
|
||||
|
||||
finished_runs = list(
|
||||
self.session.scalars(
|
||||
select(SyncRun).where(SyncRun.started_at >= since, SyncRun.finished_at.is_not(None))
|
||||
)
|
||||
)
|
||||
if finished_runs:
|
||||
successful = sum(
|
||||
1 for run in finished_runs if run.status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL)
|
||||
)
|
||||
success_rate_recent = (successful / len(finished_runs)) * 100
|
||||
else:
|
||||
success_rate_recent = None
|
||||
|
||||
return DashboardSummary(
|
||||
rider_total=rider_total,
|
||||
rider_enabled=rider_enabled,
|
||||
imported_recent=imported_recent,
|
||||
success_rate_recent=success_rate_recent,
|
||||
action_required_count=action_required_count,
|
||||
)
|
||||
|
||||
|
||||
class ActivityRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def _require(self, activity_id: int) -> Activity:
|
||||
activity = self.session.get(Activity, activity_id)
|
||||
if activity is None:
|
||||
raise ValueError(f"activity {activity_id} not found")
|
||||
return activity
|
||||
|
||||
def get(self, activity_id: int) -> Activity | None:
|
||||
return self.session.get(Activity, activity_id)
|
||||
|
||||
def get_or_create_discovered(
|
||||
self,
|
||||
*,
|
||||
@@ -76,3 +179,163 @@ class ActivityRepository:
|
||||
raise
|
||||
return existing, False
|
||||
return activity, True
|
||||
|
||||
def mark_downloaded(self, activity_id: int, path: str) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
activity.source_fit_path = path
|
||||
activity.status = ActivityStatus.DOWNLOADED
|
||||
activity.last_completed_stage = ActivityStatus.DOWNLOADED
|
||||
activity.last_error = None
|
||||
activity.retryable = True
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def mark_converted(self, activity_id: int, path: str) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
activity.converted_fit_path = path
|
||||
activity.status = ActivityStatus.CONVERTED
|
||||
activity.last_completed_stage = ActivityStatus.CONVERTED
|
||||
activity.last_error = None
|
||||
activity.retryable = True
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def mark_imported(self, activity_id: int, garmin_activity_id: str | None) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
activity.status = ActivityStatus.IMPORTED
|
||||
activity.last_completed_stage = ActivityStatus.IMPORTED
|
||||
activity.garmin_activity_id = garmin_activity_id
|
||||
activity.last_error = None
|
||||
activity.retryable = False
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def mark_duplicate(self, activity_id: int) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
activity.status = ActivityStatus.DUPLICATE
|
||||
activity.last_completed_stage = ActivityStatus.DUPLICATE
|
||||
activity.last_error = None
|
||||
activity.retryable = False
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def mark_failed(self, activity_id: int, error: str, *, retryable: bool) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
activity.status = ActivityStatus.FAILED
|
||||
activity.last_error = error[:2000]
|
||||
activity.retryable = retryable
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def reset_retryable_failure(self, activity_id: int) -> Activity:
|
||||
activity = self._require(activity_id)
|
||||
if activity.status != ActivityStatus.FAILED or not activity.retryable:
|
||||
raise ValueError("activity is not retryable")
|
||||
activity.status = activity.last_completed_stage
|
||||
activity.last_error = None
|
||||
self.session.commit()
|
||||
return activity
|
||||
|
||||
def list_pending_for_user(self, user_id: int) -> list[Activity]:
|
||||
return list(
|
||||
self.session.scalars(
|
||||
select(Activity).where(
|
||||
Activity.user_id == user_id,
|
||||
or_(
|
||||
Activity.status.in_([ActivityStatus.DISCOVERED, ActivityStatus.DOWNLOADED, ActivityStatus.CONVERTED]),
|
||||
and_(Activity.status == ActivityStatus.FAILED, Activity.retryable.is_(True)),
|
||||
),
|
||||
).order_by(Activity.id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SystemLogRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def add(self, *, source: str, message: str, user_id: int | None = None) -> SystemLogEntry:
|
||||
entry = SystemLogEntry(source=source, message=message[:2000], user_id=user_id)
|
||||
self.session.add(entry)
|
||||
self.session.commit()
|
||||
return entry
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[SystemLogEntry]:
|
||||
return list(
|
||||
self.session.scalars(
|
||||
select(SystemLogEntry)
|
||||
.order_by(SystemLogEntry.created_at.desc(), SystemLogEntry.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SchedulerSettingsRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def get_or_create(self, *, default_minutes: int = 5) -> SchedulerSettings:
|
||||
row = self.session.get(SchedulerSettings, _SCHEDULER_SETTINGS_ID)
|
||||
if row is None:
|
||||
row = SchedulerSettings(
|
||||
id=_SCHEDULER_SETTINGS_ID,
|
||||
day_interval_minutes=default_minutes,
|
||||
night_interval_minutes=default_minutes,
|
||||
)
|
||||
self.session.add(row)
|
||||
self.session.commit()
|
||||
return row
|
||||
|
||||
def update(self, row: SchedulerSettings, **values) -> SchedulerSettings:
|
||||
for key, value in values.items():
|
||||
setattr(row, key, value)
|
||||
self.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
class SyncRunRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def start(self, user_id: int) -> SyncRun:
|
||||
sync_run = SyncRun(user_id=user_id, status=SyncRunStatus.RUNNING)
|
||||
self.session.add(sync_run)
|
||||
self.session.commit()
|
||||
return sync_run
|
||||
|
||||
def get(self, sync_run_id: int) -> SyncRun | None:
|
||||
return self.session.get(SyncRun, sync_run_id)
|
||||
|
||||
def list_recent_for_user(self, user_id: int, limit: int = 10) -> list[SyncRun]:
|
||||
return list(
|
||||
self.session.scalars(
|
||||
select(SyncRun)
|
||||
.where(SyncRun.user_id == user_id)
|
||||
.order_by(SyncRun.started_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
|
||||
def finish(
|
||||
self,
|
||||
sync_run_id: int,
|
||||
*,
|
||||
status: SyncRunStatus,
|
||||
discovered: int,
|
||||
imported: int,
|
||||
skipped: int,
|
||||
failed: int,
|
||||
summary_error: str | None = None,
|
||||
) -> SyncRun:
|
||||
sync_run = self.session.get(SyncRun, sync_run_id)
|
||||
if sync_run is None:
|
||||
raise ValueError(f"sync_run {sync_run_id} not found")
|
||||
sync_run.finished_at = utcnow()
|
||||
sync_run.status = status
|
||||
sync_run.discovered_count = discovered
|
||||
sync_run.imported_count = imported
|
||||
sync_run.skipped_count = skipped
|
||||
sync_run.failed_count = failed
|
||||
sync_run.summary_error = summary_error[:2000] if summary_error else None
|
||||
self.session.commit()
|
||||
return sync_run
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.db.models import Base
|
||||
|
||||
# Columns added to existing tables after their initial release. create_all()
|
||||
# only creates missing tables, never adds columns to tables that already
|
||||
# exist, so a column added to a model here must also be listed below or an
|
||||
# already-deployed database will never receive it and the app will crash
|
||||
# reading/writing that column.
|
||||
_ADDITIVE_COLUMNS: dict[str, list[tuple[str, str]]] = {
|
||||
"sync_users": [
|
||||
("notify_email_enabled", "BOOLEAN NOT NULL DEFAULT 0"),
|
||||
("notification_email", "VARCHAR(255)"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def create_db_engine(database_url: str) -> Engine:
|
||||
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
||||
@@ -16,3 +28,24 @@ def create_session_factory(engine: Engine) -> sessionmaker[Session]:
|
||||
|
||||
def initialize_schema(engine: Engine) -> None:
|
||||
Base.metadata.create_all(engine)
|
||||
_apply_additive_migrations(engine)
|
||||
|
||||
|
||||
def _apply_additive_migrations(engine: Engine) -> None:
|
||||
if engine.dialect.name != "sqlite":
|
||||
# ALTER TABLE ... ADD COLUMN syntax/type names below are only
|
||||
# verified against sqlite, the only backend this app is deployed
|
||||
# against; a fresh create_all() on another backend already has every
|
||||
# current column, so skipping here only matters for a pre-existing
|
||||
# non-sqlite database, which does not exist in practice.
|
||||
return
|
||||
inspector = inspect(engine)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
with engine.begin() as conn:
|
||||
for table, columns in _ADDITIVE_COLUMNS.items():
|
||||
if table not in existing_tables:
|
||||
continue
|
||||
existing_columns = {col["name"] for col in inspector.get_columns(table)}
|
||||
for name, ddl_type in columns:
|
||||
if name not in existing_columns:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {ddl_type}"))
|
||||
|
||||
@@ -18,18 +18,40 @@ class UploadResult:
|
||||
raw_response: Any
|
||||
|
||||
|
||||
class GarminUploadBlocked(RuntimeError):
|
||||
class GarminError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class GarminAuthError(RuntimeError):
|
||||
class GarminUploadBlocked(GarminError):
|
||||
pass
|
||||
|
||||
|
||||
class GarminTransientError(RuntimeError):
|
||||
class GarminAuthError(GarminError):
|
||||
pass
|
||||
|
||||
|
||||
class GarminTransientError(GarminError):
|
||||
pass
|
||||
|
||||
|
||||
class GarminImportRejected(GarminError):
|
||||
pass
|
||||
|
||||
|
||||
_TRANSIENT_ERROR_TOKENS = (
|
||||
"timeout",
|
||||
"temporar",
|
||||
"connection",
|
||||
"429",
|
||||
"too many",
|
||||
"rate limit",
|
||||
"500",
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
)
|
||||
|
||||
|
||||
class GarminUploader:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -56,9 +78,12 @@ class GarminUploader:
|
||||
if _looks_duplicate_error(exc):
|
||||
return UploadResult("duplicate", True, None, str(exc))
|
||||
text = str(exc).lower()
|
||||
if any(token in text for token in ("timeout", "temporar", "connection", "502", "503", "504")):
|
||||
if any(token in text for token in _TRANSIENT_ERROR_TOKENS):
|
||||
raise GarminTransientError("Garmin import failed transiently") from exc
|
||||
if any(token in text for token in ("password", "credential", "unauthorized", "401")):
|
||||
raise GarminAuthError("Garmin import failed: authentication rejected") from exc
|
||||
raise
|
||||
_raise_if_import_rejected(response)
|
||||
return UploadResult("imported", False, _extract_activity_id(response), response)
|
||||
finally:
|
||||
self._mfa_code = None
|
||||
@@ -66,7 +91,7 @@ class GarminUploader:
|
||||
def _ensure_client(self) -> GarminClientProtocol:
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
self.tokenstore.mkdir(parents=True, exist_ok=True)
|
||||
self.tokenstore.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
factory = self.client_factory or _default_garmin_factory
|
||||
client = factory(self.email, self.password, prompt_mfa=self._prompt_mfa)
|
||||
try:
|
||||
@@ -79,9 +104,9 @@ class GarminUploader:
|
||||
raise GarminUploadBlocked("Garmin MFA is required") from exc
|
||||
if any(token in text for token in ("password", "credential", "unauthorized", "401")):
|
||||
raise GarminAuthError("Garmin authentication failed") from exc
|
||||
if any(token in text for token in ("timeout", "temporar", "connection", "502", "503", "504")):
|
||||
if any(token in text for token in _TRANSIENT_ERROR_TOKENS):
|
||||
raise GarminTransientError("Garmin login failed transiently") from exc
|
||||
raise GarminAuthError("Garmin login failed") from exc
|
||||
raise GarminTransientError("Garmin login failed") from exc
|
||||
self._client = client
|
||||
return client
|
||||
|
||||
@@ -98,10 +123,25 @@ def _default_garmin_factory(*args: Any, **kwargs: Any) -> GarminClientProtocol:
|
||||
|
||||
|
||||
def _looks_duplicate_error(exc: Exception) -> bool:
|
||||
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
if status_code == 409:
|
||||
return True
|
||||
text = str(exc).lower()
|
||||
return any(token in text for token in ("duplicate", "already exists", "409"))
|
||||
|
||||
|
||||
def _raise_if_import_rejected(response: Any) -> None:
|
||||
if not isinstance(response, dict):
|
||||
return
|
||||
detailed = response.get("detailedImportResult")
|
||||
if not isinstance(detailed, dict):
|
||||
return
|
||||
failures = detailed.get("failures")
|
||||
successes = detailed.get("successes")
|
||||
if isinstance(failures, list) and failures and not successes:
|
||||
raise GarminImportRejected(f"Garmin rejected the import ({len(failures)} failure(s))")
|
||||
|
||||
|
||||
def _extract_activity_id(response: Any) -> str | None:
|
||||
if not isinstance(response, dict):
|
||||
return None
|
||||
|
||||
67
app/main.py
67
app/main.py
@@ -1,8 +1,21 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db.session import create_db_engine, create_session_factory, initialize_schema
|
||||
from app.fit.rewriter import convert_fit_device
|
||||
from app.garmin.uploader import GarminUploader
|
||||
from app.mywhoosh.client import MyWhooshClient
|
||||
from app.notifications.emailer import EmailNotifier
|
||||
from app.security.credentials import CredentialCipher
|
||||
from app.sync.manager import SyncManager
|
||||
from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler
|
||||
from app.web.account import router as account_router
|
||||
from app.web.operations import router as operations_router
|
||||
from app.web.routes import router as web_router
|
||||
|
||||
|
||||
@@ -12,7 +25,52 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
resolved.tokens_dir.mkdir(parents=True, exist_ok=True)
|
||||
resolved.activities_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="MyWhoosh Garmin Sync")
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
cipher = CredentialCipher(resolved.credential_encryption_key)
|
||||
|
||||
def mywhoosh_factory(token_store):
|
||||
return MyWhooshClient(token_store)
|
||||
|
||||
def garmin_factory(email, password, tokenstore):
|
||||
return GarminUploader(email=email, password=password, tokenstore=tokenstore)
|
||||
|
||||
notifier = EmailNotifier(
|
||||
host=resolved.smtp_host,
|
||||
port=resolved.smtp_port,
|
||||
username=resolved.smtp_username,
|
||||
password=resolved.smtp_password,
|
||||
from_address=resolved.smtp_from_address,
|
||||
use_tls=resolved.smtp_use_tls,
|
||||
)
|
||||
|
||||
sync_manager = SyncManager(
|
||||
session_factory=app.state.session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=resolved,
|
||||
mywhoosh_factory=mywhoosh_factory,
|
||||
garmin_factory=garmin_factory,
|
||||
fit_converter=convert_fit_device,
|
||||
notifier=notifier,
|
||||
)
|
||||
app.state.sync_manager = sync_manager
|
||||
|
||||
interval_provider = DayNightIntervalProvider(
|
||||
app.state.session_factory, default_minutes=resolved.sync_interval_minutes
|
||||
)
|
||||
scheduler = SyncScheduler(
|
||||
sync_manager,
|
||||
interval_seconds=resolved.sync_interval_minutes * 60,
|
||||
interval_provider=interval_provider,
|
||||
)
|
||||
app.state.scheduler = scheduler
|
||||
await scheduler.start()
|
||||
|
||||
yield
|
||||
|
||||
await scheduler.stop()
|
||||
|
||||
app = FastAPI(title="MyWhoosh Garmin Sync", lifespan=lifespan)
|
||||
app.state.settings = resolved
|
||||
|
||||
engine = create_db_engine(resolved.database_url)
|
||||
@@ -27,6 +85,13 @@ 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.include_router(account_router)
|
||||
app.mount(
|
||||
"/static",
|
||||
StaticFiles(directory=str(Path(__file__).resolve().parent / "web" / "static")),
|
||||
name="static",
|
||||
)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict[str, str]:
|
||||
|
||||
@@ -20,6 +20,10 @@ class MyWhooshAuthError(MyWhooshError):
|
||||
pass
|
||||
|
||||
|
||||
class MyWhooshDeviceConflictError(MyWhooshAuthError):
|
||||
pass
|
||||
|
||||
|
||||
class MyWhooshTransientError(MyWhooshError):
|
||||
pass
|
||||
|
||||
@@ -31,9 +35,20 @@ class MyWhooshIntegrationError(MyWhooshError):
|
||||
class MyWhooshClient:
|
||||
def __init__(self, token_store: MyWhooshTokenStore, http_client: httpx.AsyncClient | None = None) -> None:
|
||||
self.token_store = token_store
|
||||
self._owns_http = http_client is None
|
||||
self.http = http_client or httpx.AsyncClient(timeout=30.0)
|
||||
self.token = token_store.load()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._owns_http:
|
||||
await self.http.aclose()
|
||||
|
||||
async def __aenter__(self) -> "MyWhooshClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def login(self, email: str, password: str) -> None:
|
||||
payload = {
|
||||
"Username": email,
|
||||
@@ -41,7 +56,7 @@ class MyWhooshClient:
|
||||
"Platform": "Android",
|
||||
"Action": 1001,
|
||||
"CorrelationId": str(uuid.uuid4()),
|
||||
"DeviceId": str(uuid.uuid4()),
|
||||
"DeviceId": self.token_store.get_or_create_device_id(),
|
||||
"Authorization": "",
|
||||
}
|
||||
try:
|
||||
@@ -56,8 +71,13 @@ class MyWhooshClient:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise MyWhooshIntegrationError("MyWhoosh login returned invalid JSON") from exc
|
||||
if not isinstance(body, dict):
|
||||
raise MyWhooshIntegrationError("MyWhoosh login response is not a JSON object")
|
||||
if body.get("Success") is not True or not body.get("AccessToken"):
|
||||
raise MyWhooshAuthError(str(body.get("Message") or "MyWhoosh login failed"))
|
||||
message = str(body.get("Message") or "MyWhoosh login failed")
|
||||
if "another device" in message.lower():
|
||||
raise MyWhooshDeviceConflictError(message)
|
||||
raise MyWhooshAuthError(message)
|
||||
self.token = MyWhooshToken(
|
||||
access_token=str(body["AccessToken"]),
|
||||
refresh_token=str(body["RefreshToken"]) if body.get("RefreshToken") else None,
|
||||
@@ -72,7 +92,8 @@ class MyWhooshClient:
|
||||
async def _authenticated_post(self, url: str, payload: dict, email: str, password: str) -> httpx.Response:
|
||||
await self.ensure_authenticated(email, password)
|
||||
for attempt in range(2):
|
||||
assert self.token is not None
|
||||
if self.token is None:
|
||||
raise MyWhooshIntegrationError("no token after ensure_authenticated")
|
||||
try:
|
||||
response = await self.http.post(
|
||||
url,
|
||||
@@ -81,7 +102,9 @@ class MyWhooshClient:
|
||||
)
|
||||
except httpx.TransportError as exc:
|
||||
raise MyWhooshTransientError("MyWhoosh request failed") from exc
|
||||
if response.status_code not in {401, 403}:
|
||||
if response.status_code == 403:
|
||||
raise MyWhooshAuthError(f"MyWhoosh returned HTTP {response.status_code}")
|
||||
if response.status_code != 401:
|
||||
if response.status_code >= 500:
|
||||
raise MyWhooshTransientError(f"MyWhoosh returned HTTP {response.status_code}")
|
||||
return response
|
||||
@@ -91,13 +114,15 @@ class MyWhooshClient:
|
||||
await self.login(email, password)
|
||||
continue
|
||||
raise MyWhooshAuthError("MyWhoosh session rejected after reauthentication")
|
||||
raise AssertionError("unreachable")
|
||||
raise MyWhooshIntegrationError("unreachable state in _authenticated_post")
|
||||
|
||||
async def list_activities(self, email: str, password: str) -> list[MyWhooshActivity]:
|
||||
async def list_activities(
|
||||
self, email: str, password: str, max_pages: int | None = None
|
||||
) -> list[MyWhooshActivity]:
|
||||
activities: list[MyWhooshActivity] = []
|
||||
page = 1
|
||||
total_pages = 1
|
||||
while page <= total_pages:
|
||||
while page <= total_pages and (max_pages is None or page <= max_pages):
|
||||
response = await self._authenticated_post(
|
||||
ACTIVITIES_BASE + "rider/profile/activities",
|
||||
{"sortDate": "DESC", "page": page},
|
||||
@@ -119,26 +144,30 @@ class MyWhooshClient:
|
||||
if not isinstance(results, list):
|
||||
raise MyWhooshIntegrationError("MyWhoosh activities response has unexpected shape")
|
||||
for row in results:
|
||||
activities.append(self._normalize_activity(row))
|
||||
activity = self._normalize_activity(row)
|
||||
if activity is not None:
|
||||
activities.append(activity)
|
||||
page += 1
|
||||
return activities
|
||||
|
||||
def _normalize_activity(self, row: object) -> MyWhooshActivity:
|
||||
def _normalize_activity(self, row: object) -> MyWhooshActivity | None:
|
||||
if not isinstance(row, dict):
|
||||
raise MyWhooshIntegrationError("MyWhoosh activity row is not an object")
|
||||
activity_id = row.get("id")
|
||||
activity_file_id = row.get("activityFileId")
|
||||
if not activity_id or not activity_file_id:
|
||||
raise MyWhooshIntegrationError("MyWhoosh activity row missing stable id or activityFileId")
|
||||
if activity_id is None or activity_id == "" or activity_file_id is None or activity_file_id == "":
|
||||
return None
|
||||
raw_started = row.get("startDatetime")
|
||||
started_at: datetime | None = None
|
||||
if raw_started:
|
||||
try:
|
||||
started_at = datetime.fromisoformat(str(raw_started).replace("Z", "+00:00")).astimezone(
|
||||
timezone.utc
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise MyWhooshIntegrationError("MyWhoosh activity row has invalid startDatetime") from exc
|
||||
parsed = datetime.fromisoformat(str(raw_started).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
started_at = parsed.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
started_at = parsed.astimezone(timezone.utc)
|
||||
return MyWhooshActivity(
|
||||
id=str(activity_id),
|
||||
title=str(row.get("title") or ""),
|
||||
@@ -155,7 +184,13 @@ class MyWhooshClient:
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise MyWhooshIntegrationError(f"download metadata returned HTTP {response.status_code}")
|
||||
url = response.json().get("data")
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise MyWhooshIntegrationError("MyWhoosh download response returned invalid JSON") from exc
|
||||
if not isinstance(body, dict):
|
||||
raise MyWhooshIntegrationError("MyWhoosh download response is not a JSON object")
|
||||
url = body.get("data")
|
||||
if not isinstance(url, str) or not url:
|
||||
raise MyWhooshIntegrationError("MyWhoosh download response has no URL")
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.mywhoosh.models import MyWhooshToken
|
||||
@@ -8,17 +9,18 @@ from app.mywhoosh.models import MyWhooshToken
|
||||
class MyWhooshTokenStore:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
self.device_id_path = path.with_name("device_id")
|
||||
|
||||
def load(self) -> MyWhooshToken | None:
|
||||
try:
|
||||
raw = json.loads(self.path.read_text("utf-8"))
|
||||
except FileNotFoundError:
|
||||
return MyWhooshToken(
|
||||
access_token=raw["access_token"],
|
||||
refresh_token=raw.get("refresh_token"),
|
||||
whoosh_id=raw.get("whoosh_id"),
|
||||
)
|
||||
except (FileNotFoundError, OSError, ValueError, KeyError, TypeError):
|
||||
return None
|
||||
return MyWhooshToken(
|
||||
access_token=raw["access_token"],
|
||||
refresh_token=raw.get("refresh_token"),
|
||||
whoosh_id=raw.get("whoosh_id"),
|
||||
)
|
||||
|
||||
def save(self, token: MyWhooshToken) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
@@ -40,3 +42,16 @@ class MyWhooshTokenStore:
|
||||
|
||||
def clear(self) -> None:
|
||||
self.path.unlink(missing_ok=True)
|
||||
|
||||
def get_or_create_device_id(self) -> str:
|
||||
try:
|
||||
existing = self.device_id_path.read_text("utf-8").strip()
|
||||
except (FileNotFoundError, OSError):
|
||||
existing = ""
|
||||
if existing:
|
||||
return existing
|
||||
device_id = str(uuid.uuid4())
|
||||
self.device_id_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
self.device_id_path.write_text(device_id, "utf-8")
|
||||
os.chmod(self.device_id_path, 0o600)
|
||||
return device_id
|
||||
|
||||
0
app/notifications/__init__.py
Normal file
0
app/notifications/__init__.py
Normal file
42
app/notifications/emailer.py
Normal file
42
app/notifications/emailer.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
|
||||
class EmailNotifier:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str | None,
|
||||
port: int,
|
||||
username: str | None,
|
||||
password: str | None,
|
||||
from_address: str | None,
|
||||
use_tls: bool,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.from_address = from_address
|
||||
self.use_tls = use_tls
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.host and self.from_address)
|
||||
|
||||
def send(self, *, to_address: str, subject: str, body: str) -> None:
|
||||
if not self.configured:
|
||||
return
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = self.from_address
|
||||
message["To"] = to_address
|
||||
message.set_content(body)
|
||||
with smtplib.SMTP(self.host, self.port, timeout=10) as smtp:
|
||||
if self.use_tls:
|
||||
smtp.starttls()
|
||||
if self.username and self.password:
|
||||
smtp.login(self.username, self.password)
|
||||
smtp.send_message(message)
|
||||
0
app/sync/__init__.py
Normal file
0
app/sync/__init__.py
Normal file
455
app/sync/manager.py
Normal file
455
app/sync/manager.py
Normal file
@@ -0,0 +1,455 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.db.models import ActivityStatus, HealthState, SyncRunStatus
|
||||
from app.db.repositories import ActivityRepository, SyncRunRepository, SystemLogRepository, UserRepository
|
||||
from app.fit.rewriter import FitFormatError
|
||||
from app.garmin.uploader import (
|
||||
GarminAuthError,
|
||||
GarminImportRejected,
|
||||
GarminTransientError,
|
||||
GarminUploadBlocked,
|
||||
)
|
||||
from app.mywhoosh.client import (
|
||||
MyWhooshAuthError,
|
||||
MyWhooshDeviceConflictError,
|
||||
MyWhooshIntegrationError,
|
||||
MyWhooshTransientError,
|
||||
)
|
||||
from app.mywhoosh.tokenstore import MyWhooshTokenStore
|
||||
from app.security.credentials import CredentialCipher
|
||||
from app.sync.states import SyncOutcome
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncAlreadyRunning(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# action_reason values that are resolved purely by this run's MyWhoosh listing
|
||||
# succeeding (reaching the end of the activity loop with stop_user_run still
|
||||
# False already proves MyWhoosh is working again -- every MyWhoosh-related
|
||||
# exception branch that could fire also sets stop_user_run=True).
|
||||
_MYWHOOSH_ACTION_REASONS = frozenset(
|
||||
{"mywhoosh_auth_required", "mywhoosh_integration_changed", "mywhoosh_device_conflict"}
|
||||
)
|
||||
|
||||
# action_reason values that can only be resolved by actual, this-run evidence
|
||||
# of a successful Garmin import -- the mere absence of a Garmin exception does
|
||||
# NOT prove anything, since no Garmin work may have been attempted this run.
|
||||
_GARMIN_ACTION_REASONS = frozenset({"garmin_mfa_required", "garmin_auth_required"})
|
||||
|
||||
|
||||
class SyncManager:
|
||||
"""Resumable single-user MyWhoosh -> Garmin sync pipeline.
|
||||
|
||||
`_sync_user_locked` implements the state machine for one user's sync run.
|
||||
`sync_user` wraps it with a per-user `asyncio.Lock` so only one sync can
|
||||
run for a given user at a time (raising `SyncAlreadyRunning` on overlap),
|
||||
and `sync_all_enabled` fans out across all enabled users, isolating each
|
||||
user's failure to its own result rather than cancelling siblings.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_factory: Callable[[], Any],
|
||||
credential_cipher: CredentialCipher,
|
||||
settings: Any,
|
||||
mywhoosh_factory: Callable[[MyWhooshTokenStore], Any],
|
||||
garmin_factory: Callable[[str, str, Path], Any],
|
||||
fit_converter: Callable[[Path, Path], Any],
|
||||
notifier: Any = None,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.credential_cipher = credential_cipher
|
||||
self.settings = settings
|
||||
self.mywhoosh_factory = mywhoosh_factory
|
||||
self.garmin_factory = garmin_factory
|
||||
self.fit_converter = fit_converter
|
||||
self.notifier = notifier
|
||||
self._locks: dict[int, asyncio.Lock] = {}
|
||||
self._locks_guard = asyncio.Lock()
|
||||
|
||||
def _notify_action_required(self, session: Any, user: Any, message: str | None) -> None:
|
||||
if self.notifier is None or not user.notify_email_enabled or not user.notification_email:
|
||||
return
|
||||
# A notifier with no SMTP host configured silently no-ops in send()
|
||||
# rather than raising -- without this check, that silence would look
|
||||
# identical to "everything's fine" in the system log, leaving an
|
||||
# opted-in user with no way to find out why no mail ever arrives.
|
||||
if not getattr(self.notifier, "configured", True):
|
||||
self._record_log(
|
||||
session,
|
||||
(
|
||||
f"Skipped action-required email to {user.notification_email} for user {user.id} "
|
||||
f"({user.name}): SMTP is not configured (SMTP_HOST unset)."
|
||||
),
|
||||
user_id=user.id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
self.notifier.send(
|
||||
to_address=user.notification_email,
|
||||
subject=f"MyWhoosh-Garmin Sync: action required for {user.name}",
|
||||
body=message or "Your sync requires attention. Check the dashboard for details.",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"sync_user: failed to send action-required notification for user %s", user.id, exc_info=True
|
||||
)
|
||||
self._record_log(
|
||||
session,
|
||||
(
|
||||
f"Failed to email {user.notification_email} for user {user.id} ({user.name}): "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
),
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
def _record_log(self, session: Any, message: str, *, user_id: int | None = None) -> None:
|
||||
try:
|
||||
SystemLogRepository(session).add(source="email_notification", message=message, user_id=user_id)
|
||||
except Exception:
|
||||
logger.warning("sync_user: failed to record email-notification entry in system log", exc_info=True)
|
||||
|
||||
async def _lock_for(self, user_id: int) -> asyncio.Lock:
|
||||
async with self._locks_guard:
|
||||
return self._locks.setdefault(user_id, asyncio.Lock())
|
||||
|
||||
async def sync_user(self, user_id: int, mfa_code: str | None = None) -> SyncOutcome:
|
||||
lock = await self._lock_for(user_id)
|
||||
if lock.locked():
|
||||
raise SyncAlreadyRunning(f"sync already running for user {user_id}")
|
||||
async with lock:
|
||||
return await self._sync_user_locked(user_id, mfa_code)
|
||||
|
||||
def _load_enabled_user_ids(self) -> list[int]:
|
||||
with self.session_factory() as session:
|
||||
return [user.id for user in UserRepository(session).list_enabled()]
|
||||
|
||||
async def sync_all_enabled(self) -> list[SyncOutcome | Exception]:
|
||||
user_ids = self._load_enabled_user_ids()
|
||||
return await asyncio.gather(
|
||||
*(self.sync_user(user_id) for user_id in user_ids),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def _sync_user_locked(self, user_id: int, mfa_code: str | None = None) -> SyncOutcome:
|
||||
with self.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f"user {user_id} not found")
|
||||
|
||||
# Captured before this run mutates action_reason, so the
|
||||
# end-of-run notification below only fires on a transition into
|
||||
# (or between) action-required states -- never repeatedly for a
|
||||
# cause that's already been reported and still unresolved.
|
||||
previous_action_reason = user.action_reason
|
||||
|
||||
sync_run_repo = SyncRunRepository(session)
|
||||
run = sync_run_repo.start(user_id)
|
||||
|
||||
# Best-available counters, tracked outside the inner try so that if
|
||||
# something raises before/while they'd normally be populated, the
|
||||
# outer except below can still report whatever we do know.
|
||||
discovered = 0
|
||||
imported_count = 0
|
||||
skipped_count = 0
|
||||
failed_count = 0
|
||||
mywhoosh = None
|
||||
|
||||
try:
|
||||
try:
|
||||
mw_email = self.credential_cipher.decrypt(user.mywhoosh_email_enc)
|
||||
mw_password = self.credential_cipher.decrypt(user.mywhoosh_password_enc)
|
||||
garmin_email = self.credential_cipher.decrypt(user.garmin_email_enc)
|
||||
garmin_password = self.credential_cipher.decrypt(user.garmin_password_enc)
|
||||
|
||||
token_dir = self.settings.tokens_dir / str(user.id)
|
||||
mywhoosh = self.mywhoosh_factory(MyWhooshTokenStore(token_dir / "mywhoosh.json"))
|
||||
garmin = self.garmin_factory(garmin_email, garmin_password, token_dir / "garmin")
|
||||
|
||||
activity_repo = ActivityRepository(session)
|
||||
stop_user_run = False
|
||||
summary_error: str | None = None
|
||||
# True only once an actual, this-run Garmin import/duplicate
|
||||
# check has succeeded for a real activity -- the sole
|
||||
# evidence that can justify clearing a Garmin-class
|
||||
# action_reason (see _GARMIN_ACTION_REASONS below).
|
||||
garmin_succeeded_this_run = False
|
||||
|
||||
try:
|
||||
remote_activities = await mywhoosh.list_activities(mw_email, mw_password)
|
||||
except MyWhooshTransientError as exc:
|
||||
user.health_state = HealthState.DEGRADED
|
||||
user.mywhoosh_state = "error"
|
||||
session.commit()
|
||||
remote_activities = []
|
||||
stop_user_run = True
|
||||
summary_error = str(exc)
|
||||
except MyWhooshDeviceConflictError as exc:
|
||||
user.health_state = HealthState.ACTION_REQUIRED
|
||||
user.mywhoosh_state = "device_conflict"
|
||||
user.action_reason = "mywhoosh_device_conflict"
|
||||
session.commit()
|
||||
remote_activities = []
|
||||
stop_user_run = True
|
||||
summary_error = str(exc)
|
||||
except MyWhooshAuthError as exc:
|
||||
user.health_state = HealthState.ACTION_REQUIRED
|
||||
user.mywhoosh_state = "auth_required"
|
||||
user.action_reason = "mywhoosh_auth_required"
|
||||
session.commit()
|
||||
remote_activities = []
|
||||
stop_user_run = True
|
||||
summary_error = str(exc)
|
||||
except MyWhooshIntegrationError as exc:
|
||||
user.health_state = HealthState.ACTION_REQUIRED
|
||||
user.mywhoosh_state = "integration_error"
|
||||
user.action_reason = "mywhoosh_integration_changed"
|
||||
session.commit()
|
||||
remote_activities = []
|
||||
stop_user_run = True
|
||||
summary_error = str(exc)
|
||||
except Exception as exc:
|
||||
user.health_state = HealthState.DEGRADED
|
||||
user.mywhoosh_state = "error"
|
||||
session.commit()
|
||||
remote_activities = []
|
||||
stop_user_run = True
|
||||
summary_error = f"{type(exc).__name__}: {str(exc)[:200]}"
|
||||
logger.warning(
|
||||
"sync_user: unexpected error listing activities for user %s: %s",
|
||||
user.id,
|
||||
exc.__class__.__name__,
|
||||
)
|
||||
else:
|
||||
user.mywhoosh_state = "connected"
|
||||
session.commit()
|
||||
|
||||
discovered = len(remote_activities)
|
||||
|
||||
for remote in remote_activities:
|
||||
if stop_user_run:
|
||||
break
|
||||
|
||||
activity, _created = activity_repo.get_or_create_discovered(
|
||||
user_id=user.id,
|
||||
mywhoosh_activity_id=remote.id,
|
||||
activity_name=remote.title,
|
||||
activity_timestamp=remote.started_at,
|
||||
)
|
||||
|
||||
# Non-retryable failures (e.g. corrupt/unsupported FIT
|
||||
# files) are terminal: never re-attempt them, and don't
|
||||
# count them in any counter for this run.
|
||||
if activity.status == ActivityStatus.FAILED and not activity.retryable:
|
||||
continue
|
||||
|
||||
stage = (
|
||||
activity.last_completed_stage
|
||||
if activity.status == ActivityStatus.FAILED
|
||||
else activity.status
|
||||
)
|
||||
initial_stage = stage
|
||||
|
||||
# Use the DB's own numeric primary key rather than the
|
||||
# upstream-supplied mywhoosh_activity_id as a directory
|
||||
# component: activity.id is always a safe integer, so
|
||||
# this avoids any path-traversal risk from an
|
||||
# unsanitized remote id (e.g. "../../etc") while
|
||||
# remaining just as stable across resumed syncs.
|
||||
activity_dir = self.settings.activities_dir / str(user.id) / str(activity.id)
|
||||
source_path = activity_dir / "source.fit"
|
||||
converted_path = activity_dir / "edge-1030-plus.fit"
|
||||
|
||||
try:
|
||||
if stage == ActivityStatus.DISCOVERED:
|
||||
fit_bytes = await mywhoosh.download_fit(
|
||||
remote.activity_file_id, mw_email, mw_password
|
||||
)
|
||||
activity_dir.mkdir(parents=True, exist_ok=True)
|
||||
source_path.write_bytes(fit_bytes)
|
||||
activity = activity_repo.mark_downloaded(activity.id, str(source_path))
|
||||
stage = activity.status
|
||||
|
||||
if stage in {ActivityStatus.DOWNLOADED}:
|
||||
self.fit_converter(source_path, converted_path)
|
||||
activity = activity_repo.mark_converted(activity.id, str(converted_path))
|
||||
stage = activity.status
|
||||
|
||||
if stage in {ActivityStatus.CONVERTED}:
|
||||
upload = await asyncio.to_thread(garmin.import_fit, converted_path, mfa_code)
|
||||
if upload.duplicate:
|
||||
activity = activity_repo.mark_duplicate(activity.id)
|
||||
else:
|
||||
activity = activity_repo.mark_imported(activity.id, upload.garmin_activity_id)
|
||||
stage = activity.status
|
||||
user.garmin_state = "connected"
|
||||
garmin_succeeded_this_run = True
|
||||
|
||||
# Only count this activity's outcome toward this
|
||||
# run's totals if the state machine actually did
|
||||
# work this call. An activity that was already
|
||||
# terminal (IMPORTED/DUPLICATE) before this call is
|
||||
# resume history, not this run's work.
|
||||
if initial_stage not in (ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE):
|
||||
if activity.status == ActivityStatus.IMPORTED:
|
||||
imported_count += 1
|
||||
elif activity.status == ActivityStatus.DUPLICATE:
|
||||
skipped_count += 1
|
||||
|
||||
except MyWhooshTransientError as exc:
|
||||
user.health_state = HealthState.DEGRADED
|
||||
user.mywhoosh_state = "error"
|
||||
activity_repo.mark_failed(activity.id, str(exc), retryable=True)
|
||||
failed_count += 1
|
||||
except MyWhooshDeviceConflictError as exc:
|
||||
user.health_state = HealthState.ACTION_REQUIRED
|
||||
user.mywhoosh_state = "device_conflict"
|
||||
user.action_reason = "mywhoosh_device_conflict"
|
||||
stop_user_run = True
|
||||
summary_error = str(exc)
|
||||
except MyWhooshAuthError as exc:
|
||||
user.health_state = HealthState.ACTION_REQUIRED
|
||||
user.mywhoosh_state = "auth_required"
|
||||
user.action_reason = "mywhoosh_auth_required"
|
||||
stop_user_run = True
|
||||
summary_error = str(exc)
|
||||
except MyWhooshIntegrationError as exc:
|
||||
user.health_state = HealthState.ACTION_REQUIRED
|
||||
user.mywhoosh_state = "integration_error"
|
||||
user.action_reason = "mywhoosh_integration_changed"
|
||||
stop_user_run = True
|
||||
summary_error = str(exc)
|
||||
except GarminUploadBlocked as exc:
|
||||
user.health_state = HealthState.ACTION_REQUIRED
|
||||
user.garmin_state = "mfa_required"
|
||||
user.action_reason = "garmin_mfa_required"
|
||||
stop_user_run = True
|
||||
summary_error = str(exc)
|
||||
except GarminAuthError as exc:
|
||||
user.health_state = HealthState.ACTION_REQUIRED
|
||||
user.garmin_state = "auth_required"
|
||||
user.action_reason = "garmin_auth_required"
|
||||
stop_user_run = True
|
||||
summary_error = str(exc)
|
||||
except GarminTransientError as exc:
|
||||
user.health_state = HealthState.DEGRADED
|
||||
user.garmin_state = "error"
|
||||
activity_repo.mark_failed(activity.id, str(exc), retryable=True)
|
||||
failed_count += 1
|
||||
except GarminImportRejected as exc:
|
||||
# Garmin-side permanent content rejection (real
|
||||
# failures, no successes in detailedImportResult):
|
||||
# same category as a corrupt/unsupported FIT file --
|
||||
# a non-retryable per-activity failure, no user
|
||||
# health/state change.
|
||||
activity_repo.mark_failed(activity.id, str(exc), retryable=False)
|
||||
failed_count += 1
|
||||
except FitFormatError as exc:
|
||||
activity_repo.mark_failed(activity.id, str(exc), retryable=False)
|
||||
failed_count += 1
|
||||
except Exception as exc:
|
||||
user.health_state = HealthState.DEGRADED
|
||||
activity_repo.mark_failed(
|
||||
activity.id,
|
||||
f"{type(exc).__name__}: {str(exc)[:200]}",
|
||||
retryable=True,
|
||||
)
|
||||
failed_count += 1
|
||||
logger.warning(
|
||||
"sync_user: unexpected error for user %s activity %s: %s",
|
||||
user.id,
|
||||
activity.id,
|
||||
exc.__class__.__name__,
|
||||
)
|
||||
|
||||
session.commit()
|
||||
|
||||
if not stop_user_run:
|
||||
# Reaching here with stop_user_run still False proves
|
||||
# this run's MyWhoosh listing succeeded (every
|
||||
# MyWhoosh-exception branch above also sets
|
||||
# stop_user_run=True) -- so a MyWhoosh-class
|
||||
# action_reason is always safe to clear here. It does
|
||||
# NOT prove any Garmin problem was fixed: a Garmin-class
|
||||
# action_reason may only be cleared when this run
|
||||
# actually succeeded at a real Garmin import
|
||||
# (garmin_succeeded_this_run). Otherwise the
|
||||
# action-required condition is still live and
|
||||
# unverified, so leave both action_reason and
|
||||
# health_state untouched.
|
||||
reason = user.action_reason
|
||||
can_clear = (
|
||||
reason is None
|
||||
or reason in _MYWHOOSH_ACTION_REASONS
|
||||
or (reason in _GARMIN_ACTION_REASONS and garmin_succeeded_this_run)
|
||||
)
|
||||
if can_clear:
|
||||
user.action_reason = None
|
||||
user.health_state = (
|
||||
HealthState.DEGRADED if failed_count > 0 else HealthState.HEALTHY
|
||||
)
|
||||
session.commit()
|
||||
|
||||
status = (
|
||||
SyncRunStatus.SUCCESS
|
||||
if failed_count == 0 and not stop_user_run
|
||||
else SyncRunStatus.PARTIAL
|
||||
if (imported_count + skipped_count) > 0
|
||||
else SyncRunStatus.FAILED
|
||||
)
|
||||
sync_run_repo.finish(
|
||||
run.id,
|
||||
status=status,
|
||||
discovered=discovered,
|
||||
imported=imported_count,
|
||||
skipped=skipped_count,
|
||||
failed=failed_count,
|
||||
summary_error=summary_error,
|
||||
)
|
||||
session.commit()
|
||||
if user.action_reason is not None and user.action_reason != previous_action_reason:
|
||||
self._notify_action_required(session, user, summary_error)
|
||||
return SyncOutcome(
|
||||
user_id=user.id,
|
||||
status=status.value,
|
||||
discovered=discovered,
|
||||
imported=imported_count,
|
||||
skipped=skipped_count,
|
||||
failed=failed_count,
|
||||
message=summary_error,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Any unhandled exception escaping the block above (e.g. a
|
||||
# credential-decrypt failure after key rotation, a factory
|
||||
# constructor raising, an unexpected DB error) would
|
||||
# otherwise leave this SyncRun stuck at
|
||||
# status=RUNNING/finished_at=NULL forever. Finish it as
|
||||
# FAILED with whatever counters we do have, then
|
||||
# re-propagate so callers (e.g. sync_all_enabled's
|
||||
# asyncio.gather(return_exceptions=True)) still see it.
|
||||
session.rollback()
|
||||
sync_run_repo.finish(
|
||||
run.id,
|
||||
status=SyncRunStatus.FAILED,
|
||||
discovered=discovered,
|
||||
imported=imported_count,
|
||||
skipped=skipped_count,
|
||||
failed=failed_count,
|
||||
summary_error=f"{type(exc).__name__}: {str(exc)[:200]}",
|
||||
)
|
||||
session.commit()
|
||||
raise
|
||||
finally:
|
||||
if mywhoosh is not None:
|
||||
aclose = getattr(mywhoosh, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
116
app/sync/scheduler.py
Normal file
116
app/sync/scheduler.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable
|
||||
|
||||
from app.db.repositories import SchedulerSettingsRepository
|
||||
from app.sync.manager import SyncAlreadyRunning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_daytime(hour: int, day_start_hour: int, night_start_hour: int) -> bool:
|
||||
"""True if `hour` falls within [day_start_hour, night_start_hour),
|
||||
handling a day period that wraps past midnight (e.g. day_start=20,
|
||||
night_start=6). Equal start hours are treated as "always day"."""
|
||||
if day_start_hour == night_start_hour:
|
||||
return True
|
||||
if day_start_hour < night_start_hour:
|
||||
return day_start_hour <= hour < night_start_hour
|
||||
return hour >= day_start_hour or hour < night_start_hour
|
||||
|
||||
|
||||
class DayNightIntervalProvider:
|
||||
"""Reads the admin-configurable day/night interval from the DB on every
|
||||
call, so a change made via the UI takes effect on the scheduler's very
|
||||
next tick without an app restart."""
|
||||
|
||||
def __init__(self, session_factory, *, default_minutes: int, now: Callable[[], datetime] | None = None) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.default_minutes = default_minutes
|
||||
self._now = now or datetime.now
|
||||
|
||||
def __call__(self) -> float:
|
||||
with self.session_factory() as session:
|
||||
row = SchedulerSettingsRepository(session).get_or_create(default_minutes=self.default_minutes)
|
||||
day_start_hour = row.day_start_hour
|
||||
night_start_hour = row.night_start_hour
|
||||
day_interval_minutes = row.day_interval_minutes
|
||||
night_interval_minutes = row.night_interval_minutes
|
||||
|
||||
hour = self._now().hour
|
||||
minutes = (
|
||||
day_interval_minutes
|
||||
if is_daytime(hour, day_start_hour, night_start_hour)
|
||||
else night_interval_minutes
|
||||
)
|
||||
return minutes * 60
|
||||
|
||||
|
||||
class SyncScheduler:
|
||||
def __init__(
|
||||
self,
|
||||
manager,
|
||||
*,
|
||||
interval_seconds: float,
|
||||
interval_provider: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
self.manager = manager
|
||||
self.interval_seconds = interval_seconds
|
||||
self.interval_provider = interval_provider
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stop = asyncio.Event()
|
||||
self.last_tick = None
|
||||
self.next_tick = None
|
||||
|
||||
def _current_interval(self) -> float:
|
||||
if self.interval_provider is None:
|
||||
return self.interval_seconds
|
||||
try:
|
||||
return float(self.interval_provider())
|
||||
except Exception:
|
||||
logger.warning("interval_provider failed, falling back to default interval", exc_info=True)
|
||||
return self.interval_seconds
|
||||
|
||||
async def run_once(self, interval: float | None = None) -> None:
|
||||
if interval is None:
|
||||
interval = self._current_interval()
|
||||
self.last_tick = datetime.now(timezone.utc)
|
||||
try:
|
||||
results = await self.manager.sync_all_enabled()
|
||||
for result in results:
|
||||
if not isinstance(result, Exception):
|
||||
continue
|
||||
# SyncAlreadyRunning is an expected, benign outcome when a
|
||||
# manual sync and a scheduler tick overlap for the same user
|
||||
# -- not an error worth logging.
|
||||
if isinstance(result, SyncAlreadyRunning):
|
||||
continue
|
||||
logger.warning(
|
||||
"sync_all_enabled: user sync failed during scheduled tick: %s: %s",
|
||||
type(result).__name__,
|
||||
str(result)[:200],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("sync_all_enabled failed during scheduled tick")
|
||||
finally:
|
||||
self.next_tick = datetime.now(timezone.utc) + timedelta(seconds=interval)
|
||||
|
||||
async def _run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
interval = self._current_interval()
|
||||
await self.run_once(interval)
|
||||
try:
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=interval)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
async def start(self) -> None:
|
||||
self._stop.clear()
|
||||
self._task = asyncio.create_task(self._run())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._task is not None:
|
||||
await self._task
|
||||
self._task = None
|
||||
12
app/sync/states.py
Normal file
12
app/sync/states.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyncOutcome:
|
||||
user_id: int
|
||||
status: str
|
||||
discovered: int
|
||||
imported: int
|
||||
skipped: int
|
||||
failed: int
|
||||
message: str | None = None
|
||||
161
app/web/account.py
Normal file
161
app/web/account.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.account import authenticate_self_service
|
||||
from app.auth.admin import require_self_service
|
||||
from app.auth.csrf import ensure_csrf_token, validate_csrf
|
||||
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
|
||||
from app.security.credentials import CredentialCipher
|
||||
from app.sync.manager import SyncAlreadyRunning
|
||||
from app.web.operations import _outcome_toast, _toast_html
|
||||
from app.web.routes import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _cipher(request: Request) -> CredentialCipher:
|
||||
return CredentialCipher(request.app.state.settings.credential_encryption_key)
|
||||
|
||||
|
||||
def _require_non_empty(value: str, field_name: str) -> None:
|
||||
if not value.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{field_name} must not be empty",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/account-login", response_class=HTMLResponse)
|
||||
def account_login_page(request: Request):
|
||||
return templates.TemplateResponse(request, "account_login.html", {"csrf_token": ensure_csrf_token(request)})
|
||||
|
||||
|
||||
@router.post("/account-login")
|
||||
def account_login(
|
||||
request: Request,
|
||||
csrf_token: str = Form(...),
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
cipher = _cipher(request)
|
||||
with request.app.state.session_factory() as session:
|
||||
user = authenticate_self_service(session, cipher, email, password)
|
||||
if user is None:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"account_login.html",
|
||||
{"csrf_token": ensure_csrf_token(request), "error": "Invalid email or password"},
|
||||
status_code=401,
|
||||
)
|
||||
request.session["self_service_user_id"] = user.id
|
||||
return RedirectResponse("/account", status_code=303)
|
||||
|
||||
|
||||
@router.post("/account-logout")
|
||||
def account_logout(request: Request, csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
request.session.pop("self_service_user_id", None)
|
||||
return RedirectResponse("/account-login", status_code=303)
|
||||
|
||||
|
||||
def _get_own_user_or_reauth(request: Request, repository: UserRepository, user_id: int):
|
||||
user = repository.get(user_id)
|
||||
if user is None:
|
||||
request.session.pop("self_service_user_id", None)
|
||||
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/account-login"})
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/account", response_class=HTMLResponse)
|
||||
def account_detail(request: Request):
|
||||
user_id = require_self_service(request)
|
||||
with request.app.state.session_factory() as session:
|
||||
user = _get_own_user_or_reauth(request, UserRepository(session), user_id)
|
||||
activities = ActivityRepository(session).list_pending_for_user(user_id)
|
||||
recent_runs = SyncRunRepository(session).list_recent_for_user(user_id, limit=10)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"account/detail.html",
|
||||
{
|
||||
"csrf_token": ensure_csrf_token(request),
|
||||
"user": user,
|
||||
"activities": activities,
|
||||
"recent_runs": recent_runs,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/account/sync", response_class=HTMLResponse)
|
||||
async def account_sync(request: Request, csrf_token: str = Form(...)):
|
||||
user_id = require_self_service(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
try:
|
||||
outcome = await request.app.state.sync_manager.sync_user(user_id)
|
||||
with request.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
label = user.name if user is not None else "Account"
|
||||
message, level = _outcome_toast(outcome, label)
|
||||
except SyncAlreadyRunning:
|
||||
with request.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
label = user.name if user is not None else "Account"
|
||||
message, level = f"{label}: sync already running", "info"
|
||||
status_html = (
|
||||
templates.get_template("fragments/account_status.html").render(user=user) if user is not None else ""
|
||||
)
|
||||
return HTMLResponse(status_html + _toast_html(message, level))
|
||||
|
||||
|
||||
@router.get("/account/edit", response_class=HTMLResponse)
|
||||
def account_edit_page(request: Request):
|
||||
user_id = require_self_service(request)
|
||||
cipher = _cipher(request)
|
||||
with request.app.state.session_factory() as session:
|
||||
user = _get_own_user_or_reauth(request, UserRepository(session), user_id)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"account/form.html",
|
||||
{
|
||||
"csrf_token": ensure_csrf_token(request),
|
||||
"user": user,
|
||||
"mywhoosh_email": cipher.decrypt(user.mywhoosh_email_enc),
|
||||
"garmin_email": cipher.decrypt(user.garmin_email_enc),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/account/edit")
|
||||
def account_update(
|
||||
request: Request,
|
||||
csrf_token: str = Form(...),
|
||||
mywhoosh_email: str = Form(""),
|
||||
mywhoosh_password: str = Form(""),
|
||||
garmin_email: str = Form(""),
|
||||
garmin_password: str = Form(""),
|
||||
notify_email_enabled: str | None = Form(None),
|
||||
notification_email: str = Form(""),
|
||||
):
|
||||
user_id = require_self_service(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
_require_non_empty(mywhoosh_email, "mywhoosh_email")
|
||||
_require_non_empty(garmin_email, "garmin_email")
|
||||
notify_enabled = notify_email_enabled is not None
|
||||
if notify_enabled:
|
||||
_require_non_empty(notification_email, "notification_email")
|
||||
cipher = _cipher(request)
|
||||
with request.app.state.session_factory() as session:
|
||||
repository = UserRepository(session)
|
||||
user = _get_own_user_or_reauth(request, repository, user_id)
|
||||
values = {
|
||||
"mywhoosh_email_enc": cipher.encrypt(mywhoosh_email.strip()),
|
||||
"garmin_email_enc": cipher.encrypt(garmin_email.strip()),
|
||||
"notify_email_enabled": notify_enabled,
|
||||
"notification_email": notification_email.strip() or None,
|
||||
}
|
||||
if mywhoosh_password:
|
||||
values["mywhoosh_password_enc"] = cipher.encrypt(mywhoosh_password)
|
||||
if garmin_password:
|
||||
values["garmin_password_enc"] = cipher.encrypt(garmin_password)
|
||||
repository.update(user, **values)
|
||||
return RedirectResponse("/account", status_code=303)
|
||||
@@ -9,3 +9,5 @@ class UserFormData:
|
||||
garmin_email: str
|
||||
garmin_password: str
|
||||
enabled: bool
|
||||
notify_email_enabled: bool = False
|
||||
notification_email: str = ""
|
||||
|
||||
195
app/web/operations.py
Normal file
195
app/web/operations.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
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 ActivityRepository, SchedulerSettingsRepository, SystemLogRepository, UserRepository
|
||||
from app.sync.manager import SyncAlreadyRunning
|
||||
from app.web.routes import templates
|
||||
|
||||
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 {
|
||||
"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)
|
||||
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:
|
||||
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)
|
||||
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()
|
||||
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)
|
||||
async def garmin_mfa(request: Request, user_id: int, csrf_token: str = Form(...), code: str = Form(...)):
|
||||
require_admin(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
stripped = code.strip()
|
||||
if not stripped or len(stripped) > 20:
|
||||
raise HTTPException(status_code=400, detail="Invalid MFA code")
|
||||
try:
|
||||
outcome = await request.app.state.sync_manager.sync_user(user_id, mfa_code=stripped)
|
||||
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("/activities/{activity_id}/retry", response_class=HTMLResponse)
|
||||
async def retry_activity(request: Request, activity_id: int, csrf_token: str = Form(...)):
|
||||
require_admin(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
with request.app.state.session_factory() as session:
|
||||
activity_repo = ActivityRepository(session)
|
||||
try:
|
||||
activity = activity_repo.reset_retryable_failure(activity_id)
|
||||
except ValueError:
|
||||
return HTMLResponse("Activity is not retryable", status_code=409)
|
||||
user_id = activity.user_id
|
||||
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.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
|
||||
log_entries = SystemLogRepository(session).list_recent(limit=50)
|
||||
scheduler_settings = SchedulerSettingsRepository(session).get_or_create(
|
||||
default_minutes=settings.sync_interval_minutes
|
||||
)
|
||||
return templates.TemplateResponse(request, "system.html", {
|
||||
"csrf_token": ensure_csrf_token(request),
|
||||
"app_version": APP_VERSION,
|
||||
"last_tick": scheduler.last_tick,
|
||||
"next_tick": scheduler.next_tick,
|
||||
"user_count": user_count,
|
||||
"activity_count": activity_count,
|
||||
"log_entries": log_entries,
|
||||
"scheduler_settings": scheduler_settings,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/system/scheduler-settings")
|
||||
def update_scheduler_settings(
|
||||
request: Request,
|
||||
csrf_token: str = Form(...),
|
||||
day_start_hour: int = Form(...),
|
||||
night_start_hour: int = Form(...),
|
||||
day_interval_minutes: int = Form(...),
|
||||
night_interval_minutes: int = Form(...),
|
||||
):
|
||||
require_admin(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
if not (0 <= day_start_hour <= 23) or not (0 <= night_start_hour <= 23):
|
||||
raise HTTPException(status_code=400, detail="Hours must be between 0 and 23")
|
||||
if day_interval_minutes < 1 or night_interval_minutes < 1:
|
||||
raise HTTPException(status_code=400, detail="Intervals must be at least 1 minute")
|
||||
settings = request.app.state.settings
|
||||
with request.app.state.session_factory() as session:
|
||||
repository = SchedulerSettingsRepository(session)
|
||||
row = repository.get_or_create(default_minutes=settings.sync_interval_minutes)
|
||||
repository.update(
|
||||
row,
|
||||
day_start_hour=day_start_hour,
|
||||
night_start_hour=night_start_hour,
|
||||
day_interval_minutes=day_interval_minutes,
|
||||
night_interval_minutes=night_interval_minutes,
|
||||
)
|
||||
return RedirectResponse("/system", status_code=303)
|
||||
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||
@@ -6,15 +8,41 @@ from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.auth.admin import password_matches, require_admin
|
||||
from app.auth.csrf import ensure_csrf_token, validate_csrf
|
||||
from app.db.models import SyncUser
|
||||
from app.db.repositories import UserRepository
|
||||
from app.db.models import SyncUser, utcnow
|
||||
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
|
||||
from app.security.credentials import CredentialCipher
|
||||
from app.web.forms import UserFormData
|
||||
|
||||
DASHBOARD_SUMMARY_WINDOW = timedelta(days=7)
|
||||
CACHE_BUSTED_STATIC_FILES = ("style.css", "app.js", "htmx.min.js")
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
||||
|
||||
|
||||
def _compute_static_version(static_dir: Path) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
for name in CACHE_BUSTED_STATIC_FILES:
|
||||
try:
|
||||
hasher.update((static_dir / name).read_bytes())
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return hasher.hexdigest()[:10]
|
||||
|
||||
|
||||
templates.env.globals["static_version"] = _compute_static_version(
|
||||
Path(__file__).resolve().parent / "static"
|
||||
)
|
||||
|
||||
|
||||
def _next_sync_tick(request: Request):
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
return getattr(scheduler, "next_tick", None)
|
||||
|
||||
|
||||
templates.env.globals["next_sync_tick"] = _next_sync_tick
|
||||
|
||||
|
||||
def _get_user_or_404(repository: UserRepository, user_id: int) -> SyncUser:
|
||||
user = repository.get(user_id)
|
||||
if user is None:
|
||||
@@ -62,11 +90,13 @@ def login(
|
||||
def dashboard(request: Request):
|
||||
require_admin(request)
|
||||
with request.app.state.session_factory() as session:
|
||||
users = UserRepository(session).list_all()
|
||||
repository = UserRepository(session)
|
||||
rows = repository.dashboard_rows()
|
||||
summary = repository.dashboard_summary(since=utcnow() - DASHBOARD_SUMMARY_WINDOW)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{"users": users, "csrf_token": ensure_csrf_token(request)},
|
||||
{"rows": rows, "summary": summary, "csrf_token": ensure_csrf_token(request)},
|
||||
)
|
||||
|
||||
|
||||
@@ -96,6 +126,8 @@ def create_user(
|
||||
garmin_email: str = Form(""),
|
||||
garmin_password: str = Form(""),
|
||||
enabled: str | None = Form(None),
|
||||
notify_email_enabled: str | None = Form(None),
|
||||
notification_email: str = Form(""),
|
||||
):
|
||||
require_admin(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
@@ -110,7 +142,11 @@ def create_user(
|
||||
garmin_email=garmin_email,
|
||||
garmin_password=garmin_password,
|
||||
enabled=enabled is not None,
|
||||
notify_email_enabled=notify_email_enabled is not None,
|
||||
notification_email=notification_email,
|
||||
)
|
||||
if form.notify_email_enabled:
|
||||
_require_non_empty(form.notification_email, "notification_email")
|
||||
cipher = _cipher(request)
|
||||
with request.app.state.session_factory() as session:
|
||||
repository = UserRepository(session)
|
||||
@@ -121,6 +157,8 @@ def create_user(
|
||||
mywhoosh_password_enc=cipher.encrypt(form.mywhoosh_password),
|
||||
garmin_email_enc=cipher.encrypt(form.garmin_email.strip()),
|
||||
garmin_password_enc=cipher.encrypt(form.garmin_password),
|
||||
notify_email_enabled=form.notify_email_enabled,
|
||||
notification_email=form.notification_email.strip() or None,
|
||||
)
|
||||
user_id = user.id
|
||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||
@@ -131,12 +169,16 @@ def user_detail(request: Request, user_id: int):
|
||||
require_admin(request)
|
||||
with request.app.state.session_factory() as session:
|
||||
user = _get_user_or_404(UserRepository(session), user_id)
|
||||
activities = ActivityRepository(session).list_pending_for_user(user_id)
|
||||
recent_runs = SyncRunRepository(session).list_recent_for_user(user_id, limit=10)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"users/detail.html",
|
||||
{
|
||||
"csrf_token": ensure_csrf_token(request),
|
||||
"user": user,
|
||||
"activities": activities,
|
||||
"recent_runs": recent_runs,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -171,6 +213,8 @@ def update_user(
|
||||
garmin_email: str = Form(""),
|
||||
garmin_password: str = Form(""),
|
||||
enabled: str | None = Form(None),
|
||||
notify_email_enabled: str | None = Form(None),
|
||||
notification_email: str = Form(""),
|
||||
):
|
||||
require_admin(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
@@ -183,7 +227,11 @@ def update_user(
|
||||
garmin_email=garmin_email,
|
||||
garmin_password=garmin_password,
|
||||
enabled=enabled is not None,
|
||||
notify_email_enabled=notify_email_enabled is not None,
|
||||
notification_email=notification_email,
|
||||
)
|
||||
if form.notify_email_enabled:
|
||||
_require_non_empty(form.notification_email, "notification_email")
|
||||
cipher = _cipher(request)
|
||||
with request.app.state.session_factory() as session:
|
||||
repository = UserRepository(session)
|
||||
@@ -193,6 +241,8 @@ def update_user(
|
||||
"enabled": form.enabled,
|
||||
"mywhoosh_email_enc": cipher.encrypt(form.mywhoosh_email.strip()),
|
||||
"garmin_email_enc": cipher.encrypt(form.garmin_email.strip()),
|
||||
"notify_email_enabled": form.notify_email_enabled,
|
||||
"notification_email": form.notification_email.strip() or None,
|
||||
}
|
||||
if form.mywhoosh_password:
|
||||
values["mywhoosh_password_enc"] = cipher.encrypt(form.mywhoosh_password)
|
||||
|
||||
57
app/web/static/app.js
Normal file
57
app/web/static/app.js
Normal file
@@ -0,0 +1,57 @@
|
||||
function formatCountdown(remainingMs) {
|
||||
if (remainingMs <= 0) {
|
||||
return "due now";
|
||||
}
|
||||
const totalSeconds = Math.floor(remainingMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
if (hours > 0) {
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
return `${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
function startSyncCountdown(el) {
|
||||
const target = new Date(el.dataset.utc);
|
||||
if (Number.isNaN(target.getTime())) {
|
||||
return;
|
||||
}
|
||||
el.title = `${target.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${el.dataset.utc} UTC`;
|
||||
|
||||
let intervalId = null;
|
||||
const tick = () => {
|
||||
const remaining = target.getTime() - Date.now();
|
||||
el.textContent = formatCountdown(remaining);
|
||||
if (remaining <= 0 && intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
if (target.getTime() - Date.now() > 0) {
|
||||
intervalId = setInterval(tick, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll("time.next-sync[data-utc]").forEach(startSyncCountdown);
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:oobAfterSwap", (event) => {
|
||||
if (event.detail.target.id !== "toast-container") {
|
||||
return;
|
||||
}
|
||||
// event.detail.target is the *old* element htmx just swapped out (an
|
||||
// outerHTML oob-swap detaches it), so look the live one up by id
|
||||
// rather than trusting that reference.
|
||||
const container = document.getElementById("toast-container");
|
||||
const toast = container ? container.querySelector(".toast") : null;
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
toast.classList.add("toast-leaving");
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 4000);
|
||||
});
|
||||
BIN
app/web/static/apple-touch-icon.png
Normal file
BIN
app/web/static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
app/web/static/favicon-16.png
Normal file
BIN
app/web/static/favicon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 647 B |
BIN
app/web/static/favicon-32.png
Normal file
BIN
app/web/static/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/web/static/favicon-48.png
Normal file
BIN
app/web/static/favicon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/web/static/favicon.ico
Normal file
BIN
app/web/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 669 B |
1
app/web/static/htmx.min.js
vendored
Normal file
1
app/web/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
app/web/static/logo.png
Normal file
BIN
app/web/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
509
app/web/static/style.css
Normal file
509
app/web/static/style.css
Normal file
@@ -0,0 +1,509 @@
|
||||
:root {
|
||||
--bg: #12151a;
|
||||
--surface: #1a1f27;
|
||||
--surface-raised: #232935;
|
||||
--border: #2a3038;
|
||||
--text: #e7eaf0;
|
||||
--text-muted: #8b93a3;
|
||||
--accent: #c8ff4d;
|
||||
--danger: #ff5f6d;
|
||||
--danger-bg: rgba(255, 95, 109, 0.16);
|
||||
--success: #c8ff4d;
|
||||
--success-bg: rgba(200, 255, 77, 0.16);
|
||||
--warning: #ffb454;
|
||||
--warning-bg: rgba(255, 180, 84, 0.16);
|
||||
--neutral: #8b93a3;
|
||||
--neutral-bg: rgba(139, 147, 163, 0.16);
|
||||
--info: #5fd4ff;
|
||||
--info-bg: rgba(95, 212, 255, 0.16);
|
||||
--radius: 10px;
|
||||
--shadow: none;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--info);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
.btn:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.9rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.topbar .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar nav {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin: 2rem 0 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
|
||||
.stat-tile-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat-tile-value {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-tile-sub {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--text-muted);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
max-width: 320px;
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.toast-danger {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-left-color: var(--info);
|
||||
}
|
||||
|
||||
.toast-leaving {
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
button.htmx-request, .btn.htmx-request {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.25rem 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: card-in 150ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.user-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.user-card .user-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.user-card .user-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.user-card .user-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 0.9rem;
|
||||
}
|
||||
|
||||
.user-card .user-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-healthy {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-syncing {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.badge-degraded {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge-action_required {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge-disabled {
|
||||
background: var(--neutral-bg);
|
||||
color: var(--neutral);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-partial {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge-failed,
|
||||
.badge-error {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge-running {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.action-required {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
button, .btn {
|
||||
font: inherit;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:hover, .btn:hover {
|
||||
filter: brightness(0.88);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button.secondary, .btn.secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
button.secondary:hover, .btn.secondary:hover {
|
||||
background: var(--surface-raised);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
table th, table td {
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
table th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
table td {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
dl.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dl.info-grid dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
dl.info-grid dd {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin: -0.4rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.summary-error {
|
||||
color: var(--danger);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
form.stacked-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
form.stacked-form label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
form.stacked-form input[type="text"],
|
||||
form.stacked-form input[type="password"],
|
||||
form.stacked-form input[type="email"],
|
||||
form.stacked-form input[type="number"] {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
form.stacked-form input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
form.stacked-form button {
|
||||
margin-top: 1rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sync-status .next-sync {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--accent);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.sync-status .next-sync::before {
|
||||
content: "\25b8 ";
|
||||
}
|
||||
69
app/web/templates/account/detail.html
Normal file
69
app/web/templates/account/detail.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Account - MyWhoosh Garmin Sync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ user.name }}</h1>
|
||||
<div class="page-actions">
|
||||
<a class="btn secondary" href="/account/edit">Edit</a>
|
||||
<form method="post" action="/account/sync" class="inline-form"
|
||||
hx-post="/account/sync" hx-target="#account-status" hx-swap="outerHTML" hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Sync now</button>
|
||||
</form>
|
||||
<form method="post" action="/account-logout" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% include "fragments/account_status.html" %}
|
||||
</div>
|
||||
|
||||
{% if user.action_reason == "mywhoosh_device_conflict" %}
|
||||
<h2>MyWhoosh device conflict</h2>
|
||||
<div class="card">
|
||||
<p>MyWhoosh reports this account is already logged in on another device. Log out of MyWhoosh there (app or website), then retry the sync.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Recent sync runs</h2>
|
||||
<div class="card">
|
||||
{% if recent_runs %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Started</th>
|
||||
<th>Finished</th>
|
||||
<th>Status</th>
|
||||
<th>Discovered</th>
|
||||
<th>Imported</th>
|
||||
<th>Skipped</th>
|
||||
<th>Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in recent_runs %}
|
||||
<tr>
|
||||
<td>{{ run.started_at }}</td>
|
||||
<td>{{ run.finished_at or "-" }}</td>
|
||||
<td><span class="badge badge-{{ run.status.value }}">{{ run.status.value }}</span></td>
|
||||
<td>{{ run.discovered_count }}</td>
|
||||
<td>{{ run.imported_count }}</td>
|
||||
<td>{{ run.skipped_count }}</td>
|
||||
<td>{{ run.failed_count }}</td>
|
||||
</tr>
|
||||
{% if run.summary_error %}
|
||||
<tr>
|
||||
<td colspan="7" class="summary-error">{{ run.summary_error }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">No sync runs yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
39
app/web/templates/account/form.html
Normal file
39
app/web/templates/account/form.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit My Account - MyWhoosh Garmin Sync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Edit My Account</h1>
|
||||
<div class="card">
|
||||
<form method="post" action="/account/edit" class="stacked-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label for="mywhoosh_email">MyWhoosh Email</label>
|
||||
<input type="email" id="mywhoosh_email" name="mywhoosh_email" value="{{ mywhoosh_email }}" required>
|
||||
|
||||
<label for="mywhoosh_password">MyWhoosh Password</label>
|
||||
<input type="password" id="mywhoosh_password" name="mywhoosh_password" autocomplete="new-password">
|
||||
<p class="hint">Leave blank to keep the existing password.</p>
|
||||
|
||||
<label for="garmin_email">Garmin Email</label>
|
||||
<input type="email" id="garmin_email" name="garmin_email" value="{{ garmin_email }}" required>
|
||||
|
||||
<label for="garmin_password">Garmin Password</label>
|
||||
<input type="password" id="garmin_password" name="garmin_password" autocomplete="new-password">
|
||||
<p class="hint">Leave blank to keep the existing password.</p>
|
||||
|
||||
<label for="notify_email_enabled">
|
||||
<input type="checkbox" id="notify_email_enabled" name="notify_email_enabled"
|
||||
{% if user.notify_email_enabled %}checked{% endif %}>
|
||||
Email me when this account needs attention
|
||||
</label>
|
||||
|
||||
<label for="notification_email">Notification email</label>
|
||||
<input type="email" id="notification_email" name="notification_email"
|
||||
value="{{ user.notification_email or '' }}">
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
<p><a href="/account">Back to my account</a></p>
|
||||
{% endblock %}
|
||||
22
app/web/templates/account_login.html
Normal file
22
app/web/templates/account_login.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Account Login - MyWhoosh Garmin Sync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Account Login</h1>
|
||||
<div class="card">
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/account-login" class="stacked-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label for="email">MyWhoosh or Garmin email</label>
|
||||
<input type="email" id="email" name="email" required autofocus>
|
||||
<label for="password">MyWhoosh or Garmin password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
<p class="hint">Use the email and password for either your MyWhoosh or your Garmin account.</p>
|
||||
</div>
|
||||
<p><a href="/login">Admin login</a></p>
|
||||
{% endblock %}
|
||||
@@ -2,9 +2,38 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}MyWhoosh Garmin Sync{% endblock %}</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16.png">
|
||||
<link rel="shortcut icon" href="/static/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ static_version }}">
|
||||
<script src="/static/htmx.min.js?v={{ static_version }}" defer></script>
|
||||
<script src="/static/app.js?v={{ static_version }}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
<header class="topbar">
|
||||
<span class="brand"><img src="/static/logo.png" alt="" class="brand-logo" width="28" height="28">MyWhoosh → Garmin Sync</span>
|
||||
<div class="topbar-right">
|
||||
<nav>
|
||||
{% if request.session.get('admin_authenticated') %}
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/system">System</a>
|
||||
{% endif %}
|
||||
{% if request.session.get('self_service_user_id') %}
|
||||
<a href="/account">My Account</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% set next_tick = next_sync_tick(request) %}
|
||||
{% if next_tick %}
|
||||
<span class="sync-status">Next sync: <time class="next-sync" datetime="{{ next_tick.isoformat() }}" data-utc="{{ next_tick.isoformat() }}">{{ next_tick.strftime('%Y-%m-%d %H:%M UTC') }}</time></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,16 +4,37 @@
|
||||
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
<p><a href="/users/new">Add user</a></p>
|
||||
<ul>
|
||||
{% for user in users %}
|
||||
<li>
|
||||
<a href="/users/{{ user.id }}">{{ user.name }}</a>
|
||||
— {{ "enabled" if user.enabled else "disabled" }}
|
||||
— {{ user.health_state.value }}
|
||||
</li>
|
||||
|
||||
<div class="stat-tiles">
|
||||
<div class="stat-tile">
|
||||
<span class="stat-tile-label">Riders</span>
|
||||
<span class="stat-tile-value">{{ summary.rider_total }}</span>
|
||||
<span class="stat-tile-sub">{{ summary.rider_enabled }} enabled</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-tile-label">Imported · 7d</span>
|
||||
<span class="stat-tile-value">{{ summary.imported_recent }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-tile-label">Success rate · 7d</span>
|
||||
<span class="stat-tile-value">{% if summary.success_rate_recent is not none %}{{ "%.0f"|format(summary.success_rate_recent) }}%{% else %}–{% endif %}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-tile-label">Action required</span>
|
||||
<span class="stat-tile-value">{{ summary.action_required_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<a class="btn secondary" href="/users/new">Add user</a>
|
||||
{% include "fragments/sync_all_form.html" %}
|
||||
</div>
|
||||
|
||||
<ul class="user-list">
|
||||
{% for row in rows %}
|
||||
{% include "fragments/user_row.html" %}
|
||||
{% else %}
|
||||
<li>No users yet.</li>
|
||||
<li class="card empty-state">No users yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
13
app/web/templates/fragments/account_status.html
Normal file
13
app/web/templates/fragments/account_status.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<dl class="info-grid" id="account-status">
|
||||
<dt>Status</dt>
|
||||
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
|
||||
|
||||
<dt>MyWhoosh state</dt>
|
||||
<dd>{{ user.mywhoosh_state }}</dd>
|
||||
|
||||
<dt>Garmin state</dt>
|
||||
<dd>{{ user.garmin_state }}</dd>
|
||||
|
||||
<dt>Action reason</dt>
|
||||
<dd>{{ user.action_reason or "-" }}</dd>
|
||||
</dl>
|
||||
6
app/web/templates/fragments/mfa_form.html
Normal file
6
app/web/templates/fragments/mfa_form.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<form method="post" action="/users/{{ user.id }}/garmin-mfa" class="stacked-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label for="mfa_code">Garmin MFA code</label>
|
||||
<input type="text" id="mfa_code" name="code" maxlength="20" required>
|
||||
<button type="submit">Submit code</button>
|
||||
</form>
|
||||
5
app/web/templates/fragments/sync_all_form.html
Normal file
5
app/web/templates/fragments/sync_all_form.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<form method="post" action="/sync-all" class="inline-form"
|
||||
hx-post="/sync-all" hx-target="this" hx-swap="outerHTML" hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Sync all now</button>
|
||||
</form>
|
||||
45
app/web/templates/fragments/sync_result.html
Normal file
45
app/web/templates/fragments/sync_result.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Sync result - MyWhoosh Garmin Sync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Sync result</h1>
|
||||
<div class="page-actions">
|
||||
<a class="btn secondary" href="/">Back to dashboard</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Status</th>
|
||||
<th>Discovered</th>
|
||||
<th>Imported</th>
|
||||
<th>Skipped</th>
|
||||
<th>Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for outcome in outcomes %}
|
||||
<tr>
|
||||
<td>{{ outcome.user_id if outcome.user_id is not none else "unknown" }}</td>
|
||||
<td><span class="badge badge-{{ outcome.status }}">{{ outcome.status }}</span></td>
|
||||
<td>{{ outcome.discovered }}</td>
|
||||
<td>{{ outcome.imported }}</td>
|
||||
<td>{{ outcome.skipped }}</td>
|
||||
<td>{{ outcome.failed }}</td>
|
||||
</tr>
|
||||
{% if outcome.message %}
|
||||
<tr>
|
||||
<td colspan="6" class="summary-error">{{ outcome.message }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="empty-state">No outcomes.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
3
app/web/templates/fragments/toast.html
Normal file
3
app/web/templates/fragments/toast.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div id="toast-container" class="toast-container" hx-swap-oob="true">
|
||||
<div class="toast toast-{{ level }}">{{ message }}</div>
|
||||
</div>
|
||||
24
app/web/templates/fragments/user_row.html
Normal file
24
app/web/templates/fragments/user_row.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<li class="card user-card" id="user-row-{{ row.id }}"{% if oob %} hx-swap-oob="true"{% endif %}>
|
||||
<div class="user-main">
|
||||
<a class="user-name" href="/users/{{ row.id }}">{{ row.name }}</a>
|
||||
<div class="user-meta">
|
||||
<span class="badge badge-{{ row.health_state }}">{{ row.health_state.replace("_", " ") }}</span>
|
||||
<span>{{ "enabled" if row.enabled else "disabled" }}</span>
|
||||
<span>last sync: {{ row.last_sync_at or "-" }}</span>
|
||||
<span>last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})</span>
|
||||
</div>
|
||||
{% if row.action_reason == "garmin_mfa_required" %}
|
||||
<span class="action-required">Garmin MFA required — <a href="/users/{{ row.id }}">resolve</a></span>
|
||||
{% elif row.action_reason == "mywhoosh_device_conflict" %}
|
||||
<span class="action-required">MyWhoosh account logged in on another device — log out there, then <a href="/users/{{ row.id }}">retry</a></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<form method="post" action="/users/{{ row.id }}/sync" class="inline-form"
|
||||
hx-post="/users/{{ row.id }}/sync" hx-target="closest .user-card" hx-swap="outerHTML"
|
||||
hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Sync now</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
@@ -4,13 +4,16 @@
|
||||
|
||||
{% block content %}
|
||||
<h1>Admin Login</h1>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autofocus>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
<div class="card">
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/login" class="stacked-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autofocus>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
</div>
|
||||
<p><a href="/account-login">Log in with your MyWhoosh or Garmin account instead</a></p>
|
||||
{% endblock %}
|
||||
|
||||
82
app/web/templates/system.html
Normal file
82
app/web/templates/system.html
Normal file
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}System - MyWhoosh Garmin Sync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>System</h1>
|
||||
|
||||
<div class="card">
|
||||
<dl class="info-grid">
|
||||
<dt>Application version</dt>
|
||||
<dd>{{ app_version }}</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>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/sync-all" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Sync all now</button>
|
||||
</form>
|
||||
|
||||
<h2>Sync scheduling</h2>
|
||||
<div class="card">
|
||||
<form method="post" action="/system/scheduler-settings" class="stacked-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label for="day_start_hour">Day starts at (hour, 0-23)</label>
|
||||
<input type="number" id="day_start_hour" name="day_start_hour" min="0" max="23"
|
||||
value="{{ scheduler_settings.day_start_hour }}" required>
|
||||
|
||||
<label for="day_interval_minutes">Sync interval during the day (minutes)</label>
|
||||
<input type="number" id="day_interval_minutes" name="day_interval_minutes" min="1"
|
||||
value="{{ scheduler_settings.day_interval_minutes }}" required>
|
||||
|
||||
<label for="night_start_hour">Night starts at (hour, 0-23)</label>
|
||||
<input type="number" id="night_start_hour" name="night_start_hour" min="0" max="23"
|
||||
value="{{ scheduler_settings.night_start_hour }}" required>
|
||||
|
||||
<label for="night_interval_minutes">Sync interval during the night (minutes)</label>
|
||||
<input type="number" id="night_interval_minutes" name="night_interval_minutes" min="1"
|
||||
value="{{ scheduler_settings.night_interval_minutes }}" required>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2>System log</h2>
|
||||
<div class="card">
|
||||
{% if log_entries %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Source</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in log_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.created_at }}</td>
|
||||
<td>{{ entry.source }}</td>
|
||||
<td>{{ entry.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">No system log entries.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4,28 +4,103 @@
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ user.name }}</h1>
|
||||
<p><a href="/users/{{ user.id }}/edit">Edit</a> | <a href="/">Back to dashboard</a></p>
|
||||
<div class="page-actions">
|
||||
<a class="btn secondary" href="/users/{{ user.id }}/edit">Edit</a>
|
||||
<a class="btn secondary" href="/">Back to dashboard</a>
|
||||
</div>
|
||||
|
||||
<dl>
|
||||
<dt>Enabled</dt>
|
||||
<dd>{{ "Yes" if user.enabled else "No" }}</dd>
|
||||
<div class="card">
|
||||
<dl class="info-grid">
|
||||
<dt>Status</dt>
|
||||
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
|
||||
|
||||
<dt>Health state</dt>
|
||||
<dd>{{ user.health_state.value }}</dd>
|
||||
<dt>Enabled</dt>
|
||||
<dd>{{ "Yes" if user.enabled else "No" }}</dd>
|
||||
|
||||
<dt>MyWhoosh state</dt>
|
||||
<dd>{{ user.mywhoosh_state }}</dd>
|
||||
<dt>MyWhoosh state</dt>
|
||||
<dd>{{ user.mywhoosh_state }}</dd>
|
||||
|
||||
<dt>Garmin state</dt>
|
||||
<dd>{{ user.garmin_state }}</dd>
|
||||
<dt>Garmin state</dt>
|
||||
<dd>{{ user.garmin_state }}</dd>
|
||||
|
||||
<dt>Action reason</dt>
|
||||
<dd>{{ user.action_reason or "-" }}</dd>
|
||||
<dt>Action reason</dt>
|
||||
<dd>{{ user.action_reason or "-" }}</dd>
|
||||
|
||||
<dt>Created at</dt>
|
||||
<dd>{{ user.created_at }}</dd>
|
||||
<dt>Created at</dt>
|
||||
<dd>{{ user.created_at }}</dd>
|
||||
|
||||
<dt>Updated at</dt>
|
||||
<dd>{{ user.updated_at }}</dd>
|
||||
</dl>
|
||||
<dt>Updated at</dt>
|
||||
<dd>{{ user.updated_at }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{% if user.action_reason == "garmin_mfa_required" %}
|
||||
<h2>Garmin MFA required</h2>
|
||||
<div class="card">
|
||||
{% include "fragments/mfa_form.html" %}
|
||||
</div>
|
||||
{% elif user.action_reason == "mywhoosh_device_conflict" %}
|
||||
<h2>MyWhoosh device conflict</h2>
|
||||
<div class="card">
|
||||
<p>MyWhoosh reports this account is already logged in on another device. Log out of MyWhoosh there (app or website), then retry the sync.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Recent sync runs</h2>
|
||||
<div class="card">
|
||||
{% if recent_runs %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Started</th>
|
||||
<th>Finished</th>
|
||||
<th>Status</th>
|
||||
<th>Discovered</th>
|
||||
<th>Imported</th>
|
||||
<th>Skipped</th>
|
||||
<th>Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in recent_runs %}
|
||||
<tr>
|
||||
<td>{{ run.started_at }}</td>
|
||||
<td>{{ run.finished_at or "-" }}</td>
|
||||
<td><span class="badge badge-{{ run.status.value }}">{{ run.status.value }}</span></td>
|
||||
<td>{{ run.discovered_count }}</td>
|
||||
<td>{{ run.imported_count }}</td>
|
||||
<td>{{ run.skipped_count }}</td>
|
||||
<td>{{ run.failed_count }}</td>
|
||||
</tr>
|
||||
{% if run.summary_error %}
|
||||
<tr>
|
||||
<td colspan="7" class="summary-error">{{ run.summary_error }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">No sync runs yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>Activities</h2>
|
||||
<div class="card">
|
||||
<ul class="user-list">
|
||||
{% for activity in activities %}
|
||||
<li>
|
||||
{{ activity.activity_name }} — {{ activity.status.value }}
|
||||
{% if activity.status.value == "failed" and activity.retryable %}
|
||||
<form method="post" action="/activities/{{ activity.id }}/retry" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Retry</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="empty-state">No pending activities.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,37 +4,49 @@
|
||||
|
||||
{% block content %}
|
||||
<h1>{% if user %}Edit User{% else %}New User{% endif %}</h1>
|
||||
<form method="post" action="{{ form_action }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="card">
|
||||
<form method="post" action="{{ form_action }}" class="stacked-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ user.name if user else '' }}" required>
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ user.name if user else '' }}" required>
|
||||
|
||||
<label for="mywhoosh_email">MyWhoosh Email</label>
|
||||
<input type="email" id="mywhoosh_email" name="mywhoosh_email" value="{{ mywhoosh_email }}" required>
|
||||
<label for="mywhoosh_email">MyWhoosh Email</label>
|
||||
<input type="email" id="mywhoosh_email" name="mywhoosh_email" value="{{ mywhoosh_email }}" required>
|
||||
|
||||
<label for="mywhoosh_password">MyWhoosh Password</label>
|
||||
<input type="password" id="mywhoosh_password" name="mywhoosh_password" autocomplete="new-password"
|
||||
{% if not user %}required{% endif %}>
|
||||
{% if user %}
|
||||
<p class="hint">Leave blank to keep the existing password.</p>
|
||||
{% endif %}
|
||||
<label for="mywhoosh_password">MyWhoosh Password</label>
|
||||
<input type="password" id="mywhoosh_password" name="mywhoosh_password" autocomplete="new-password"
|
||||
{% if not user %}required{% endif %}>
|
||||
{% if user %}
|
||||
<p class="hint">Leave blank to keep the existing password.</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="garmin_email">Garmin Email</label>
|
||||
<input type="email" id="garmin_email" name="garmin_email" value="{{ garmin_email }}" required>
|
||||
<label for="garmin_email">Garmin Email</label>
|
||||
<input type="email" id="garmin_email" name="garmin_email" value="{{ garmin_email }}" required>
|
||||
|
||||
<label for="garmin_password">Garmin Password</label>
|
||||
<input type="password" id="garmin_password" name="garmin_password" autocomplete="new-password"
|
||||
{% if not user %}required{% endif %}>
|
||||
{% if user %}
|
||||
<p class="hint">Leave blank to keep the existing password.</p>
|
||||
{% endif %}
|
||||
<label for="garmin_password">Garmin Password</label>
|
||||
<input type="password" id="garmin_password" name="garmin_password" autocomplete="new-password"
|
||||
{% if not user %}required{% endif %}>
|
||||
{% if user %}
|
||||
<p class="hint">Leave blank to keep the existing password.</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="enabled">
|
||||
<input type="checkbox" id="enabled" name="enabled" {% if not user or user.enabled %}checked{% endif %}>
|
||||
Enabled
|
||||
</label>
|
||||
<label for="enabled">
|
||||
<input type="checkbox" id="enabled" name="enabled" {% if not user or user.enabled %}checked{% endif %}>
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
|
||||
</form>
|
||||
<label for="notify_email_enabled">
|
||||
<input type="checkbox" id="notify_email_enabled" name="notify_email_enabled"
|
||||
{% if user and user.notify_email_enabled %}checked{% endif %}>
|
||||
Email me when this account needs attention
|
||||
</label>
|
||||
|
||||
<label for="notification_email">Notification email</label>
|
||||
<input type="email" id="notification_email" name="notification_email"
|
||||
value="{{ user.notification_email if user and user.notification_email else '' }}">
|
||||
|
||||
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
702
docs/superpowers/plans/2026-08-16-live-sync-updates.md
Normal file
702
docs/superpowers/plans/2026-08-16-live-sync-updates.md
Normal file
@@ -0,0 +1,702 @@
|
||||
# Live Sync Updates (HTMX) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** "Sync now" / "Sync all now" on the dashboard, and "Sync now" on the account page, update the affected rider row(s) or status block in place and show an auto-dismissing toast, instead of navigating to a separate result page.
|
||||
|
||||
**Architecture:** Vendor htmx (self-hosted) as the swap mechanism. Extract the dashboard row and the account status block into reusable Jinja partials so the same markup renders both the initial page and the post-sync response. Routes always return their own primary target's current state plus zero-or-more out-of-band updates plus exactly one out-of-band toast, so every swap is safe even on a no-op path.
|
||||
|
||||
**Tech Stack:** htmx v2.0.10 (vendored static file, no build step), plain CSS, a small addition to the existing vanilla `app.js`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-16-live-sync-updates-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No new Python dependencies; htmx is a single vendored static JS file (spec §3).
|
||||
- `fragments/sync_result.html`, the activity retry route, and the Garmin MFA route are untouched — out of scope (spec §2).
|
||||
- Every htmx POST route returns its own primary swap target's current state (never empty) plus exactly one toast; `/sync-all` additionally returns one OOB row per outcome with a resolvable `user_id` (spec §3).
|
||||
- `POST /users/{id}/sync` and `POST /account/sync` return HTTP 200 for the "already running" case now (previously 409) — existing tests for that behavior must be updated to match, per spec §2.
|
||||
- htmx's own swap/toast behavior has no meaningful Python-level test; it is verified manually via chrome-devtools, matching how the next-sync countdown was verified (spec §4).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `UserRepository.dashboard_row`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/db/repositories.py`
|
||||
- Test: `tests/db/test_repositories.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `UserRepository.dashboard_row(user_id: int) -> UserDashboardRow | None`, used by Task 4 and Task 5's routes.
|
||||
|
||||
- [x] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/db/test_repositories.py`:
|
||||
|
||||
```python
|
||||
def test_dashboard_row_returns_row_for_known_user(user_repository) -> None:
|
||||
user = _make_user(user_repository, "Alex")
|
||||
|
||||
row = user_repository.dashboard_row(user.id)
|
||||
|
||||
assert row is not None
|
||||
assert row.id == user.id
|
||||
assert row.name == "Alex"
|
||||
|
||||
|
||||
def test_dashboard_row_returns_none_for_unknown_user(user_repository) -> None:
|
||||
row = user_repository.dashboard_row(999)
|
||||
|
||||
assert row is None
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/db/test_repositories.py -k dashboard_row -v`
|
||||
Expected: both FAIL with `AttributeError: 'UserRepository' object has no attribute 'dashboard_row'`.
|
||||
|
||||
- [x] **Step 3: Extract the shared row builder and add `dashboard_row`**
|
||||
|
||||
In `app/db/repositories.py`, replace the body of `dashboard_rows` with a call to a new private helper, and add `dashboard_row`:
|
||||
|
||||
```python
|
||||
def dashboard_rows(self) -> list[UserDashboardRow]:
|
||||
return [self._build_dashboard_row(user) for user in self.list_all()]
|
||||
|
||||
def dashboard_row(self, user_id: int) -> UserDashboardRow | None:
|
||||
user = self.get(user_id)
|
||||
if user is None:
|
||||
return None
|
||||
return self._build_dashboard_row(user)
|
||||
|
||||
def _build_dashboard_row(self, user: SyncUser) -> UserDashboardRow:
|
||||
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)
|
||||
)
|
||||
return 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,
|
||||
)
|
||||
```
|
||||
|
||||
This is a pure refactor of the existing `dashboard_rows` loop body — behavior for `dashboard_rows()` itself must not change.
|
||||
|
||||
- [x] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/db/test_repositories.py -v`
|
||||
Expected: all tests pass, including the two new ones and the existing `dashboard_rows`-adjacent coverage (none currently exists directly, but nothing regresses).
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/db/repositories.py tests/db/test_repositories.py
|
||||
git commit -m "Add UserRepository.dashboard_row for single-row refresh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Vendor htmx and wire up base.html + toast/loading CSS
|
||||
|
||||
**Files:**
|
||||
- Create: `app/web/static/htmx.min.js` (vendored, v2.0.10)
|
||||
- Modify: `app/web/templates/base.html`
|
||||
- Modify: `app/web/static/style.css`
|
||||
- Test: none (static asset + markup/CSS; full suite re-run at the end of this task to confirm no regressions)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: the `#toast-container` element and `.toast`/`.toast-success`/`.toast-danger`/`.toast-info` classes that Task 3 and Task 5's `fragments/toast.html` renders into; the `.htmx-request` dimming rule.
|
||||
|
||||
- [x] **Step 1: Vendor htmx**
|
||||
|
||||
Download `https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js` and save it verbatim as `app/web/static/htmx.min.js` (already fetched once this session — reuse that content; if re-fetching, confirm the response is the same v2.0.10 minified build before saving).
|
||||
|
||||
- [x] **Step 2: Load htmx and add the toast container in `base.html`**
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
```
|
||||
|
||||
(only the new `htmx.min.js` line is added — `app.js` stays second so htmx's global is present before app.js's own listeners are registered, though with `defer` both run in document order regardless of load timing)
|
||||
|
||||
And right before `</body>`:
|
||||
|
||||
```html
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
</body>
|
||||
```
|
||||
|
||||
- [x] **Step 3: Add toast and htmx-request CSS to `style.css`**
|
||||
|
||||
```css
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--text-muted);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
max-width: 320px;
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.toast-danger {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-left-color: var(--info);
|
||||
}
|
||||
|
||||
.toast-leaving {
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
button.htmx-request, .btn.htmx-request {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: Run the full test suite**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/ -q`
|
||||
Expected: same pass count as the Task 1 baseline (no route/template behavior changed yet — only a new unused static file, an unreferenced-so-far toast container, and new CSS rules).
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/web/static/htmx.min.js app/web/templates/base.html app/web/static/style.css
|
||||
git commit -m "Vendor htmx and add toast/loading-state CSS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Dashboard row and sync-all-form partials
|
||||
|
||||
**Files:**
|
||||
- Create: `app/web/templates/fragments/user_row.html`
|
||||
- Create: `app/web/templates/fragments/sync_all_form.html`
|
||||
- Create: `app/web/templates/fragments/toast.html`
|
||||
- Modify: `app/web/templates/dashboard.html`
|
||||
- Test: `tests/web/test_dashboard_summary.py` (existing tests must keep passing — they assert on stat-tile markup, not row markup, but re-run to confirm)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `UserDashboardRow` fields (spec §3), `csrf_token`.
|
||||
- Produces: `fragments/user_row.html` renders one `<li id="user-row-<id>">`, accepting `row`, `csrf_token`, `oob` (default `False`) — Task 4's route renders this same template standalone. `fragments/toast.html` accepts `message`, `level` — Task 4 and Task 5 both render it standalone.
|
||||
|
||||
- [x] **Step 1: Create `fragments/user_row.html`**
|
||||
|
||||
```html
|
||||
<li class="card user-card" id="user-row-{{ row.id }}"{% if oob %} hx-swap-oob="true"{% endif %}>
|
||||
<div class="user-main">
|
||||
<a class="user-name" href="/users/{{ row.id }}">{{ row.name }}</a>
|
||||
<div class="user-meta">
|
||||
<span class="badge badge-{{ row.health_state }}">{{ row.health_state.replace("_", " ") }}</span>
|
||||
<span>{{ "enabled" if row.enabled else "disabled" }}</span>
|
||||
<span>last sync: {{ row.last_sync_at or "-" }}</span>
|
||||
<span>last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})</span>
|
||||
</div>
|
||||
{% if row.action_reason == "garmin_mfa_required" %}
|
||||
<span class="action-required">Garmin MFA required — <a href="/users/{{ row.id }}">resolve</a></span>
|
||||
{% elif row.action_reason == "mywhoosh_device_conflict" %}
|
||||
<span class="action-required">MyWhoosh account logged in on another device — log out there, then <a href="/users/{{ row.id }}">retry</a></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<form method="post" action="/users/{{ row.id }}/sync" class="inline-form"
|
||||
hx-post="/users/{{ row.id }}/sync" hx-target="closest .user-card" hx-swap="outerHTML"
|
||||
hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Sync now</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
```
|
||||
|
||||
- [x] **Step 2: Create `fragments/sync_all_form.html`**
|
||||
|
||||
```html
|
||||
<form method="post" action="/sync-all" class="inline-form"
|
||||
hx-post="/sync-all" hx-target="this" hx-swap="outerHTML" hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Sync all now</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
- [x] **Step 3: Create `fragments/toast.html`**
|
||||
|
||||
```html
|
||||
<div id="toast-container" hx-swap-oob="true">
|
||||
<div class="toast toast-{{ level }}">{{ message }}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [x] **Step 4: Update `dashboard.html` to use the partials**
|
||||
|
||||
Replace the `<form ... action="/sync-all" ...>` block inside `.page-actions` with:
|
||||
|
||||
```html
|
||||
{% include "fragments/sync_all_form.html" %}
|
||||
```
|
||||
|
||||
Replace the `<li class="card user-card">...</li>` block inside the `{% for row in rows %}` loop with:
|
||||
|
||||
```html
|
||||
{% include "fragments/user_row.html" %}
|
||||
```
|
||||
|
||||
(the `{% else %}No users yet.{% endif %}` branch is unchanged)
|
||||
|
||||
- [x] **Step 5: Run the full test suite**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/ -q`
|
||||
Expected: same pass count as Task 2's baseline — this is a pure template refactor (the `{% include %}` inherits `row`/`csrf_token` from the enclosing loop/page context automatically), so no existing assertion on dashboard content should break. If `tests/web/test_dashboard_summary.py` or any dashboard test fails, stop and inspect — it means the include isn't inheriting context as expected.
|
||||
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/web/templates/fragments/user_row.html app/web/templates/fragments/sync_all_form.html app/web/templates/fragments/toast.html app/web/templates/dashboard.html
|
||||
git commit -m "Extract dashboard row and sync-all form into reusable partials"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Live-update the dashboard sync routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/web/operations.py`
|
||||
- Test: `tests/web/test_operations.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `UserRepository.dashboard_row` (Task 1), `fragments/user_row.html` / `fragments/sync_all_form.html` / `fragments/toast.html` (Task 3).
|
||||
- Produces: `_toast_html(message: str, level: str) -> str` and `_outcome_toast(outcome, label: str) -> tuple[str, str]`, imported by Task 5's `app/web/account.py`.
|
||||
|
||||
- [x] **Step 1: Write the failing tests**
|
||||
|
||||
Replace the existing `test_manual_sync_reports_already_running` in `tests/web/test_operations.py` (the 409 behavior is intentionally removed — see Global Constraints) and add new coverage:
|
||||
|
||||
```python
|
||||
def test_manual_sync_updates_row_and_shows_toast(app, authenticated_client, fake_sync_manager) -> None:
|
||||
with app.state.session_factory() as session:
|
||||
from app.db.repositories import UserRepository
|
||||
user = UserRepository(session).create(
|
||||
name="Alex",
|
||||
enabled=True,
|
||||
mywhoosh_email_enc="mw",
|
||||
mywhoosh_password_enc="mw-pw",
|
||||
garmin_email_enc="g",
|
||||
garmin_password_enc="g-pw",
|
||||
)
|
||||
user_id = user.id
|
||||
|
||||
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 # the toast
|
||||
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 == 200
|
||||
assert "already running" in response.text.lower()
|
||||
|
||||
|
||||
def test_sync_all_updates_each_affected_row_and_shows_summary_toast(app, authenticated_client, fake_sync_manager) -> None:
|
||||
from app.sync.states import SyncOutcome
|
||||
|
||||
with app.state.session_factory() as session:
|
||||
from app.db.repositories import UserRepository
|
||||
user = UserRepository(session).create(
|
||||
name="Alex",
|
||||
enabled=True,
|
||||
mywhoosh_email_enc="mw",
|
||||
mywhoosh_password_enc="mw-pw",
|
||||
garmin_email_enc="g",
|
||||
garmin_password_enc="g-pw",
|
||||
)
|
||||
user_id = user.id
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Remove the old `test_manual_sync_reports_already_running` test (it asserted `status_code == 409`, which this task intentionally changes).
|
||||
|
||||
- [x] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/web/test_operations.py -v`
|
||||
Expected: the four new/changed tests FAIL (old route still returns `fragments/sync_result.html` and a 409 for already-running); other existing tests in the file still pass unchanged.
|
||||
|
||||
- [x] **Step 3: Rewrite the routes**
|
||||
|
||||
In `app/web/operations.py`, add two module-level helpers near the top (after `_normalize_outcome`):
|
||||
|
||||
```python
|
||||
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"
|
||||
```
|
||||
|
||||
Replace `manual_sync`:
|
||||
|
||||
```python
|
||||
@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)
|
||||
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:
|
||||
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 else ""
|
||||
return HTMLResponse(row_html + _toast_html(message, level))
|
||||
```
|
||||
|
||||
Replace `manual_sync_all`:
|
||||
|
||||
```python
|
||||
@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()
|
||||
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
|
||||
user_id = normalized["user_id"]
|
||||
if user_id is not None:
|
||||
row = repository.dashboard_row(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))
|
||||
```
|
||||
|
||||
Update the two remaining imports at the top of `app/web/operations.py`: `UserRepository` is already imported; `ensure_csrf_token` is already imported alongside `validate_csrf`.
|
||||
|
||||
- [x] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/web/test_operations.py -v`
|
||||
Expected: all pass.
|
||||
|
||||
- [x] **Step 5: Run the full test suite**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/ -q`
|
||||
Expected: same pass count as Task 3's baseline plus the net new tests in this task, minus the one removed 409 test (net +3 tests). No unrelated regressions.
|
||||
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/web/operations.py tests/web/test_operations.py
|
||||
git commit -m "Live-update dashboard rows and show toasts after sync actions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Live-update the account page sync route
|
||||
|
||||
**Files:**
|
||||
- Create: `app/web/templates/fragments/account_status.html`
|
||||
- Modify: `app/web/templates/account/detail.html`
|
||||
- Modify: `app/web/account.py`
|
||||
- Test: `tests/web/test_account_web.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `_toast_html`, `_outcome_toast` (Task 4, imported from `app.web.operations`).
|
||||
- Produces: nothing consumed by a later task.
|
||||
|
||||
- [x] **Step 1: Create `fragments/account_status.html`**
|
||||
|
||||
```html
|
||||
<dl class="info-grid" id="account-status">
|
||||
<dt>Status</dt>
|
||||
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
|
||||
|
||||
<dt>MyWhoosh state</dt>
|
||||
<dd>{{ user.mywhoosh_state }}</dd>
|
||||
|
||||
<dt>Garmin state</dt>
|
||||
<dd>{{ user.garmin_state }}</dd>
|
||||
|
||||
<dt>Action reason</dt>
|
||||
<dd>{{ user.action_reason or "-" }}</dd>
|
||||
</dl>
|
||||
```
|
||||
|
||||
- [x] **Step 2: Update `account/detail.html`**
|
||||
|
||||
Replace the `<div class="card"><dl class="info-grid">...</dl></div>` block with:
|
||||
|
||||
```html
|
||||
<div class="card">
|
||||
{% include "fragments/account_status.html" %}
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace the "Sync now" form in `.page-actions`:
|
||||
|
||||
```html
|
||||
<form method="post" action="/account/sync" class="inline-form"
|
||||
hx-post="/account/sync" hx-target="#account-status" hx-swap="outerHTML" hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Sync now</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
- [x] **Step 3: Write the failing tests**
|
||||
|
||||
Update `test_account_sync_reports_already_running` in `tests/web/test_account_web.py`:
|
||||
|
||||
```python
|
||||
def test_account_sync_reports_already_running_as_toast(app, client: TestClient, fake_sync_manager) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
app.state.sync_manager = fake_sync_manager
|
||||
fake_sync_manager.raise_already_running = True
|
||||
|
||||
page = client.get("/account")
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post("/account/sync", data={"csrf_token": csrf})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "already running" in response.text.lower()
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```python
|
||||
def test_account_sync_updates_status_block_and_shows_toast(app, client: TestClient, fake_sync_manager) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
app.state.sync_manager = fake_sync_manager
|
||||
|
||||
page = client.get("/account")
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post("/account/sync", data={"csrf_token": csrf})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'id="account-status"' in response.text
|
||||
assert 'hx-swap-oob="true"' in response.text
|
||||
assert "0 imported, 0 failed" in response.text
|
||||
```
|
||||
|
||||
- [x] **Step 4: Run to verify failure**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/web/test_account_web.py -v`
|
||||
Expected: the new/changed tests FAIL (route still returns `fragments/sync_result.html` / 409).
|
||||
|
||||
- [x] **Step 5: Rewrite `account_sync` in `app/web/account.py`**
|
||||
|
||||
```python
|
||||
from app.web.operations import _normalize_outcome, _outcome_toast, _toast_html
|
||||
```
|
||||
|
||||
(add `_outcome_toast, _toast_html` to the existing import line from `app.web.operations`)
|
||||
|
||||
```python
|
||||
@router.post("/account/sync", response_class=HTMLResponse)
|
||||
async def account_sync(request: Request, csrf_token: str = Form(...)):
|
||||
user_id = require_self_service(request)
|
||||
validate_csrf(request, csrf_token)
|
||||
try:
|
||||
outcome = await request.app.state.sync_manager.sync_user(user_id)
|
||||
with request.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
label = user.name if user is not None else "Account"
|
||||
message, level = _outcome_toast(outcome, label)
|
||||
except SyncAlreadyRunning:
|
||||
with request.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
label = user.name if user is not None else "Account"
|
||||
message, level = f"{label}: sync already running", "info"
|
||||
status_html = (
|
||||
templates.get_template("fragments/account_status.html").render(user=user) if user is not None else ""
|
||||
)
|
||||
return HTMLResponse(status_html + _toast_html(message, level))
|
||||
```
|
||||
|
||||
- [x] **Step 6: Run to verify pass**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/web/test_account_web.py -v`
|
||||
Expected: all pass.
|
||||
|
||||
- [x] **Step 7: Run the full test suite**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/ -q`
|
||||
Expected: same pass count as Task 4's baseline plus this task's net new tests, no unrelated regressions.
|
||||
|
||||
- [x] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/web/templates/fragments/account_status.html app/web/templates/account/detail.html app/web/account.py tests/web/test_account_web.py
|
||||
git commit -m "Live-update the account page status block after sync"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Toast auto-dismiss and manual browser verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/web/static/app.js`
|
||||
- Test: none (see Global Constraints — manual verification)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the `#toast-container` element (Task 2) and the `hx-swap-oob` toast fragments (Task 4, Task 5) that htmx swaps into it, firing its `htmx:oobAfterSwap` event.
|
||||
|
||||
- [x] **Step 1: Add the auto-dismiss listener to `app.js`**
|
||||
|
||||
Append to `app/web/static/app.js` (after the existing `DOMContentLoaded` listener, as a new top-level statement):
|
||||
|
||||
```js
|
||||
document.body.addEventListener("htmx:oobAfterSwap", (event) => {
|
||||
if (event.detail.target.id !== "toast-container") {
|
||||
return;
|
||||
}
|
||||
const toast = event.detail.target.querySelector(".toast");
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
toast.classList.add("toast-leaving");
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 4000);
|
||||
});
|
||||
```
|
||||
|
||||
- [x] **Step 2: Manually verify in a real browser via chrome-devtools**
|
||||
|
||||
Start the app locally (same approach as prior manual verifications). Log in as admin, add a rider, then:
|
||||
1. Click "Sync now" on the rider's row — confirm the row updates in place (no navigation, URL stays `/`), a toast appears top-right, and it fades out and disappears after ~4 seconds.
|
||||
2. Click "Sync all now" — confirm the button re-renders, the row updates, and a summary toast appears.
|
||||
3. Log in via `/account-login` as the same rider, click "Sync now" on the account page — confirm the status block updates in place and a toast appears.
|
||||
4. Check the DevTools console (`list_console_messages`) for errors after each of the above.
|
||||
5. Take a screenshot showing a toast visible on the dashboard.
|
||||
|
||||
Expected: no full-page navigation for any of the three actions, no console errors, toast appears and later disappears.
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/web/static/app.js
|
||||
git commit -m "Auto-dismiss sync toasts after a few seconds"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Final full-suite regression check
|
||||
|
||||
**Files:**
|
||||
- None modified — verification only.
|
||||
|
||||
- [x] **Step 1: Run the full Python test suite**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/ -q`
|
||||
Expected: baseline pass count (from before this plan) plus this plan's net new/changed tests, with the same single pre-existing unrelated Windows file-permission failure (`test_tokenstore_round_trip_and_permissions`) and nothing else.
|
||||
|
||||
- [x] **Step 2: Report completion to the user**
|
||||
|
||||
Summarize what changed and point at the Task 6 Step 2 screenshot as evidence.
|
||||
569
docs/superpowers/plans/2026-08-16-visual-redesign.md
Normal file
569
docs/superpowers/plans/2026-08-16-visual-redesign.md
Normal file
@@ -0,0 +1,569 @@
|
||||
# Visual Redesign ("Ride Computer" Dark Theme) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the app's light, generic look with a distinctive dark "bike computer cockpit" theme, and evolve the existing next-sync timestamp into a live ticking countdown, without changing any Python route, model, or template markup.
|
||||
|
||||
**Architecture:** A CSS design-token rewrite in `app/web/static/style.css` (color, typography, motion) that every existing template already picks up through shared classes (`.card`, `.badge`, `button`/`.btn`, `table`, `form.stacked-form`, `.sync-status`) — no template edits required. Separately, `app/web/static/app.js` gains a client-side countdown that ticks the already-rendered `time.next-sync[data-utc]` element down to zero, replacing the one-time UTC→local formatting it does today.
|
||||
|
||||
**Tech Stack:** Plain CSS custom properties, vanilla JS (no new dependencies, no build step — matches the existing project convention of zero frontend tooling).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-16-visual-redesign-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No new dependencies, no build step — plain CSS and vanilla JS only (spec §2, §3).
|
||||
- No route, model, or Python business-logic changes (spec §1).
|
||||
- No template markup changes — every template already consumes the shared classes this plan restyles (spec §2, §6). `base.html`'s `.topbar-right` / `.sync-status` / `time.next-sync[data-utc]` structure from the prior next-sync feature is reused as-is.
|
||||
- Dark theme only — no light-mode toggle (spec §2 "Out of scope").
|
||||
- `data-utc` stays the server↔client contract; `tests/web/test_next_sync_display.py` must keep passing unmodified (spec §4 point 5).
|
||||
- `base.html` needs **no edits** in this plan: the `.topbar-right` wrapper, the `.sync-status` span, and the `time.next-sync[data-utc]` element it needs already exist from the earlier next-sync feature. The spec's §6 rollout mentions `base.html` as a file touched by this work; in practice the existing markup already satisfies every hook Task 1's CSS and Task 2's JS need, so no template diff is required — this is a positive scope reduction, not a gap.
|
||||
- CSS token value changes and the countdown's visual behavior have **no meaningful automated test** — the user explicitly chose manual/browser verification (via chrome-devtools screenshots) over introducing a JS test runner for this work. This is a deliberate, human-approved exception to normal TDD practice for these two tasks specifically; it does not extend to any future task that adds real branching logic without the user's sign-off.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Dark cockpit color, typography, and component tokens
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/web/static/style.css` (complete rewrite — every rule below)
|
||||
- Test: none (pure CSS token values — see Global Constraints)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (leaf task, no code dependencies from other tasks).
|
||||
- Produces: the `--bg`, `--surface`, `--surface-raised`, `--border`, `--text`, `--text-muted`, `--accent`, `--danger`, `--danger-bg`, `--success`, `--success-bg`, `--warning`, `--warning-bg`, `--neutral`, `--neutral-bg`, `--info`, `--info-bg`, `--radius`, `--shadow`, `--mono` custom properties and the `.sync-status` / `.sync-status .next-sync` selectors that Task 2's markup (already shipped in `base.html`) relies on for its visual presentation.
|
||||
|
||||
- [x] **Step 1: Replace `app/web/static/style.css` with the new token system and components**
|
||||
|
||||
Replace the entire file content with:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: #12151a;
|
||||
--surface: #1a1f27;
|
||||
--surface-raised: #232935;
|
||||
--border: #2a3038;
|
||||
--text: #e7eaf0;
|
||||
--text-muted: #8b93a3;
|
||||
--accent: #c8ff4d;
|
||||
--danger: #ff5f6d;
|
||||
--danger-bg: rgba(255, 95, 109, 0.16);
|
||||
--success: #c8ff4d;
|
||||
--success-bg: rgba(200, 255, 77, 0.16);
|
||||
--warning: #ffb454;
|
||||
--warning-bg: rgba(255, 180, 84, 0.16);
|
||||
--neutral: #8b93a3;
|
||||
--neutral-bg: rgba(139, 147, 163, 0.16);
|
||||
--info: #5fd4ff;
|
||||
--info-bg: rgba(95, 212, 255, 0.16);
|
||||
--radius: 10px;
|
||||
--shadow: none;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--info);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
.btn:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.9rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.topbar .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar nav {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin: 2rem 0 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.25rem 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: card-in 150ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.user-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.user-card .user-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.user-card .user-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.user-card .user-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 0.9rem;
|
||||
}
|
||||
|
||||
.user-card .user-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-healthy {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-syncing {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.badge-degraded {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge-action_required {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge-disabled {
|
||||
background: var(--neutral-bg);
|
||||
color: var(--neutral);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-partial {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge-failed,
|
||||
.badge-error {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge-running {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.action-required {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
button, .btn {
|
||||
font: inherit;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:hover, .btn:hover {
|
||||
filter: brightness(0.88);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button.secondary, .btn.secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
button.secondary:hover, .btn.secondary:hover {
|
||||
background: var(--surface-raised);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
table th, table td {
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
table th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
table td {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
dl.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dl.info-grid dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
dl.info-grid dd {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin: -0.4rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.summary-error {
|
||||
color: var(--danger);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
form.stacked-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
form.stacked-form label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
form.stacked-form input[type="text"],
|
||||
form.stacked-form input[type="password"],
|
||||
form.stacked-form input[type="email"] {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
form.stacked-form input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
form.stacked-form button {
|
||||
margin-top: 1rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sync-status .next-sync {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--accent);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.sync-status .next-sync::before {
|
||||
content: "\25b8 ";
|
||||
}
|
||||
```
|
||||
|
||||
Notes on deliberate deviations from a literal reading of the spec, decided during implementation for consistency and to avoid template edits:
|
||||
- `table td` and `dl.info-grid dd` get monospace/tabular-nums globally (not just "stats" cells) since every table and info-grid in this app is already timestamp/count/state data, and templates aren't being touched to add per-cell classes. Badges inherit this too (they sit inside `td`), which reads like an instrument-panel status readout rather than a problem.
|
||||
- `--primary`/`--primary-hover` are removed (replaced by `--info` for links and `--accent` for buttons) since nothing in the templates references them directly (verified via grep — no inline `style="var(--...)"` usage anywhere).
|
||||
- Button hover uses `filter: brightness(0.88)` instead of a second hardcoded accent hex, keeping the palette to the named tokens in the spec.
|
||||
|
||||
- [x] **Step 2: Run the full test suite to confirm no regressions**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/ -q`
|
||||
Expected: same pass count as before this change (202 passed; the pre-existing unrelated `tests/mywhoosh/test_tokenstore.py::test_tokenstore_round_trip_and_permissions` failure on Windows is expected and untouched by this task).
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/web/static/style.css
|
||||
git commit -m "Redesign UI with dark cockpit color and typography tokens"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Live-ticking sync countdown
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/web/static/app.js` (complete rewrite)
|
||||
- Test: none (see Global Constraints — user chose manual verification over introducing a JS test runner)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the `time.next-sync[data-utc="<ISO-8601 instant>"]` element already rendered by `base.html` (from the previously shipped next-sync feature) and the `.sync-status` / `.sync-status .next-sync` CSS from Task 1.
|
||||
- Produces: nothing consumed by a later task — this is the last code task.
|
||||
|
||||
- [x] **Step 1: Replace `app/web/static/app.js` with the countdown implementation**
|
||||
|
||||
```js
|
||||
function formatCountdown(remainingMs) {
|
||||
if (remainingMs <= 0) {
|
||||
return "due now";
|
||||
}
|
||||
const totalSeconds = Math.floor(remainingMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
if (hours > 0) {
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
return `${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
function startSyncCountdown(el) {
|
||||
const target = new Date(el.dataset.utc);
|
||||
if (Number.isNaN(target.getTime())) {
|
||||
return;
|
||||
}
|
||||
el.title = `${target.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })} · ${el.dataset.utc} UTC`;
|
||||
|
||||
let intervalId = null;
|
||||
const tick = () => {
|
||||
const remaining = target.getTime() - Date.now();
|
||||
el.textContent = formatCountdown(remaining);
|
||||
if (remaining <= 0 && intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
if (target.getTime() - Date.now() > 0) {
|
||||
intervalId = setInterval(tick, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll("time.next-sync[data-utc]").forEach(startSyncCountdown);
|
||||
});
|
||||
```
|
||||
|
||||
Why `intervalId` is declared with `let` before `tick` runs once synchronously: the first `tick()` call happens before `setInterval` returns, so if the target is already due on page load, `tick` must not call `clearInterval` on a not-yet-assigned `const` — that would throw a `ReferenceError`. Declaring `intervalId` as `let intervalId = null` up front and only scheduling the interval at all when the target is still in the future avoids the bug entirely (an already-due countdown just renders "due now" once and never starts ticking).
|
||||
|
||||
- [x] **Step 2: Manually verify in a real browser via chrome-devtools**
|
||||
|
||||
Start the app locally (same approach as the next-sync feature verification: temp SQLite DB, a valid `CREDENTIAL_ENCRYPTION_KEY` from `Fernet.generate_key()`, `ADMIN_PASSWORD`/`SECRET_KEY` set, `uvicorn app.main:create_app --factory`), then:
|
||||
1. Navigate to `/login` and confirm the countdown ticks down every second (e.g. `04:58` → `04:57`).
|
||||
2. Navigate to `/` (dashboard, after admin login) and confirm the same ticking countdown appears there too.
|
||||
3. Edge case: temporarily set the fake/real scheduler's `next_tick` to a timestamp in the past (or wait past it) and reload — confirm the element shows `due now` instead of a negative or malformed countdown, and confirm no JS error appears in the DevTools console (`list_console_messages`).
|
||||
4. Take a screenshot of the dashboard and the login page to visually confirm Task 1's dark theme and Task 2's countdown render together correctly.
|
||||
|
||||
Expected: countdown ticks live, "due now" renders cleanly for an already-past target, no console errors, screenshots show the dark cockpit theme with the lime countdown readout in the topbar.
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/web/static/app.js
|
||||
git commit -m "Turn the next-sync indicator into a live ticking countdown"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Final full-suite regression check
|
||||
|
||||
**Files:**
|
||||
- None modified — verification only.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the completed state of Task 1 and Task 2.
|
||||
- Produces: nothing (terminal task).
|
||||
|
||||
- [x] **Step 1: Run the full Python test suite**
|
||||
|
||||
Run: `.venv/Scripts/python -m pytest tests/ -q`
|
||||
Expected: 202 passed, 1 pre-existing unrelated failure (`test_tokenstore_round_trip_and_permissions`, a Windows file-permission-bits issue predating this plan) — identical to the baseline recorded in Task 1 Step 2.
|
||||
|
||||
- [x] **Step 2: Report completion to the user**
|
||||
|
||||
Summarize what changed (dark cockpit theme across every page via shared CSS classes, live-ticking sync countdown) and point at the two screenshots taken in Task 2 Step 2 as evidence.
|
||||
187
docs/superpowers/specs/2026-08-16-live-sync-updates-design.md
Normal file
187
docs/superpowers/specs/2026-08-16-live-sync-updates-design.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Live Sync Updates (HTMX) — Design
|
||||
|
||||
Date: 2026-08-16
|
||||
Status: Draft for user review
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Replace the full-page navigation that currently happens after clicking
|
||||
"Sync now" / "Sync all now" with an in-place update: the affected rider
|
||||
row(s) refresh with their new status, and a short toast reports the
|
||||
outcome — without leaving the dashboard or account page.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Vendoring htmx (v2.0.10, self-hosted, no CDN) as the swap mechanism.
|
||||
- Dashboard: per-rider "Sync now" and "Sync all now".
|
||||
- Account page (self-service): "Sync now".
|
||||
- A toast notification system (one at a time, auto-dismissing) built on
|
||||
htmx out-of-band swaps.
|
||||
- Removing the HTTP 409 special case for "sync already running" — it
|
||||
becomes a normal toast instead of a distinct error page/status.
|
||||
|
||||
### Out of scope (unchanged in this pass)
|
||||
|
||||
- Activity retry button (`/activities/{id}/retry`) — still navigates to
|
||||
the old `fragments/sync_result.html` page.
|
||||
- Garmin MFA form (`/users/{id}/garmin-mfa`) — still navigates to the old
|
||||
page; MFA failure often needs a fresh code anyway, so the extra step is
|
||||
less costly there.
|
||||
- Live-updating the "Recent sync runs" table on the account/user detail
|
||||
pages — a completed sync's new row only appears after the next full
|
||||
page load.
|
||||
- Any change to `SyncManager`, `SyncOutcome`, or scheduler behavior.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### htmx
|
||||
|
||||
`app/web/static/htmx.min.js` (vendored, v2.0.10) is loaded in `base.html`
|
||||
via `<script src="/static/htmx.min.js" defer></script>`, alongside the
|
||||
existing `app.js`.
|
||||
|
||||
### The "always return current state" rule
|
||||
|
||||
Every htmx-driven POST route in scope returns two things in one response
|
||||
body:
|
||||
|
||||
1. The current, freshly-reloaded state of its own primary swap target
|
||||
(even on a no-op path like "sync already running", or on the
|
||||
`/sync-all` form itself, which always re-renders unchanged). This
|
||||
makes every swap safe/idempotent — the target is never replaced with
|
||||
nothing.
|
||||
2. Exactly one out-of-band toast fragment (`fragments/toast.html`,
|
||||
`hx-swap-oob="true"` on `#toast-container`) describing what happened.
|
||||
|
||||
`/sync-all` additionally emits one out-of-band row update
|
||||
(`fragments/user_row.html` rendered with `oob=True`) per rider whose
|
||||
outcome carries a known `user_id` — riders unaffected by that run (e.g.
|
||||
disabled) are left alone.
|
||||
|
||||
### Shared row partial
|
||||
|
||||
`app/web/templates/fragments/user_row.html` renders one
|
||||
`<li class="card user-card" id="user-row-{{ row.id }}">...</li>`, taking
|
||||
`row` (a `UserDashboardRow`), `csrf_token`, and `oob` (default `False`,
|
||||
adds `hx-swap-oob="true"` to the root element when `True`). `dashboard.html`
|
||||
`{% include %}`s it once per row in its existing loop (`oob` omitted,
|
||||
defaults to `False`) instead of inlining the `<li>` markup — this is the
|
||||
only change to the existing loop, so the initial page render is
|
||||
byte-for-byte equivalent to today's markup plus the new `hx-*` attributes
|
||||
on the row and its form.
|
||||
|
||||
### Shared account status partial
|
||||
|
||||
`app/web/templates/fragments/account_status.html` renders the
|
||||
`<dl class="info-grid" id="account-status">...</dl>` block (Status,
|
||||
MyWhoosh state, Garmin state, Action reason) that today lives inline in
|
||||
`account/detail.html`. Same include pattern.
|
||||
|
||||
### New repository method
|
||||
|
||||
`UserRepository.dashboard_row(user_id: int) -> UserDashboardRow | None`
|
||||
in `app/db/repositories.py` — the existing `dashboard_rows()` loop body is
|
||||
extracted into a private `_build_dashboard_row(user: SyncUser) -> UserDashboardRow`
|
||||
helper that both `dashboard_rows()` and the new `dashboard_row(user_id)`
|
||||
call, so there is exactly one place that assembles a row.
|
||||
|
||||
### Route changes
|
||||
|
||||
`app/web/operations.py`:
|
||||
|
||||
- `manual_sync` (`POST /users/{user_id}/sync`): on success, on
|
||||
`SyncAlreadyRunning`, and on any other outcome, always ends by opening a
|
||||
fresh session, calling `UserRepository(session).dashboard_row(user_id)`,
|
||||
and rendering `fragments/user_row.html` (`oob=False`, since this row IS
|
||||
the primary `hx-target`) followed by a toast whose message/level depend
|
||||
on the outcome. Always returns HTTP 200 now (no more 409).
|
||||
- `manual_sync_all` (`POST /sync-all`): re-renders the trigering `<form>`
|
||||
unchanged as the primary swap content (`fragments/sync_all_form.html`,
|
||||
a two-line partial holding just that form), then one
|
||||
`fragments/user_row.html` (`oob=True`) per outcome with a resolvable
|
||||
`user_id`, then one summary toast, e.g. `"Synced 3 riders — 2 ok, 1
|
||||
failed"` or `"No riders to sync"` when the outcome list is empty.
|
||||
|
||||
`app/web/account.py`:
|
||||
|
||||
- `account_sync` (`POST /account/sync`): same "always return current
|
||||
state + toast" shape, but the primary target is
|
||||
`fragments/account_status.html` re-rendered from the freshly reloaded
|
||||
`SyncUser`, not a row.
|
||||
|
||||
### Toast levels and copy
|
||||
|
||||
| Situation | Level | Message |
|
||||
|---|---|---|
|
||||
| `status in (success, partial)` | success | `"<name>: <imported> imported, <failed> failed"` |
|
||||
| `status == failed` | danger | `"<name>: sync failed — <message or 'unknown error'>"` |
|
||||
| `SyncAlreadyRunning` caught | info | `"<name>: sync already running"` |
|
||||
| Exception (unexpected) | danger | `"<name>: sync error — <message>"` |
|
||||
| `/sync-all` summary | success if all ok else danger | `"Synced <n> riders — <ok> ok, <failed> failed"` |
|
||||
| `/sync-all` with zero enabled riders | info | `"No riders to sync"` |
|
||||
|
||||
`fragments/toast.html` takes `message: str` and `level: Literal["success",
|
||||
"danger", "info"]`, rendering:
|
||||
|
||||
```html
|
||||
<div id="toast-container" hx-swap-oob="true">
|
||||
<div class="toast toast-{{ level }}">{{ message }}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
`base.html` gets an empty `<div id="toast-container" class="toast-container"></div>`
|
||||
right before `</body>` so the very first toast has something to swap.
|
||||
|
||||
### Auto-dismiss
|
||||
|
||||
`app/web/static/app.js` gains an `htmx:oobAfterSwap` listener: when the
|
||||
swapped element's id is `toast-container`, it schedules the `.toast`
|
||||
child's removal after 4 seconds via a CSS class (`toast-leaving`, an
|
||||
opacity/transform transition) added 300ms before the actual `remove()`
|
||||
call, so it fades rather than disappearing instantly.
|
||||
`prefers-reduced-motion: reduce` disables the CSS transition (the toast
|
||||
still disappears at the same 4-second mark, just without animating).
|
||||
|
||||
### CSS
|
||||
|
||||
New `.toast-container` (fixed, top-right, stacked via flex column though
|
||||
only one toast exists at a time), `.toast`, `.toast-success`,
|
||||
`.toast-danger`, `.toast-info` rules using the existing color tokens
|
||||
(`--success`/`--danger`/`--info` text on `--surface-raised` background,
|
||||
consistent with the existing badge treatment). Existing
|
||||
`button.htmx-request` / `.btn.htmx-request` rule dims the control
|
||||
(`opacity: 0.6`) while a request is in flight — htmx adds/removes this
|
||||
class automatically, no JS needed.
|
||||
|
||||
## 4. Testing
|
||||
|
||||
- `UserRepository.dashboard_row` — unit tests mirroring `dashboard_rows()`
|
||||
coverage (found user returns expected fields, unknown id returns
|
||||
`None`) in `tests/db/test_repositories.py`.
|
||||
- Route-level tests (`tests/web/test_operations.py`,
|
||||
`tests/web/test_account_web.py` or a new
|
||||
`tests/web/test_live_sync_updates.py`) using the existing `TestClient` +
|
||||
`fake_sync_manager` fixture, asserting on the returned HTML: the row's
|
||||
`id="user-row-<id>"` element is present with updated fields, a
|
||||
`hx-swap-oob="true"` toast div is present with the expected message
|
||||
class, `/sync-all` emits one OOB row per outcome, and the
|
||||
already-running path returns HTTP 200 (not 409) with an info toast.
|
||||
- No htmx JS itself is unit-testable from Python; the actual in-browser
|
||||
swap behavior (row updates without navigation, toast appears and
|
||||
disappears) is verified manually via chrome-devtools, the same way the
|
||||
next-sync countdown was verified.
|
||||
|
||||
## 5. Rollout
|
||||
|
||||
Files touched: `app/web/static/htmx.min.js` (new, vendored),
|
||||
`app/web/static/app.js`, `app/web/static/style.css`, `app/web/templates/base.html`,
|
||||
`app/web/templates/dashboard.html`, `app/web/templates/account/detail.html`,
|
||||
`app/web/templates/fragments/user_row.html` (new),
|
||||
`app/web/templates/fragments/account_status.html` (new),
|
||||
`app/web/templates/fragments/toast.html` (new),
|
||||
`app/web/templates/fragments/sync_all_form.html` (new),
|
||||
`app/db/repositories.py`, `app/web/operations.py`, `app/web/account.py`.
|
||||
`fragments/sync_result.html` is untouched (still used by retry/MFA,
|
||||
out of scope).
|
||||
157
docs/superpowers/specs/2026-08-16-visual-redesign-design.md
Normal file
157
docs/superpowers/specs/2026-08-16-visual-redesign-design.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Visual Redesign — "Ride Computer" Dark Theme — Design
|
||||
|
||||
Date: 2026-08-16
|
||||
Status: Draft for user review
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Replace the current light, generic admin-template look with a distinctive dark
|
||||
"bike computer cockpit" theme grounded in the app's own subject matter (indoor
|
||||
cycling sync, Garmin telemetry). The redesign is a shared foundation: it must
|
||||
land before the planned dashboard summary tiles and live-update UX polish, so
|
||||
those can be built directly in the new visual language instead of needing a
|
||||
second pass.
|
||||
|
||||
This spec covers only the visual system and the already-shipped next-sync
|
||||
indicator's evolution into a live countdown. It does not add new pages, new
|
||||
data, or new business logic.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- New CSS design-token system (color, spacing reuse, borders) replacing the
|
||||
current light theme in `app/web/static/style.css`.
|
||||
- Cockpit-style topbar (dark, brand mark, nav, live sync countdown).
|
||||
- Restyled shared components: cards, buttons, badges, tables, forms, empty
|
||||
states — applied globally via shared CSS classes so every existing template
|
||||
(login, account-login, dashboard, account detail/edit, users
|
||||
detail/new/edit, system, sync result fragment) picks it up without
|
||||
structural rewrites.
|
||||
- Typographic treatment: uppercase tracked labels for headings/section labels
|
||||
(extends the existing table-header pattern), tabular monospace numerals for
|
||||
all stats/timestamps/counters.
|
||||
- Evolving the existing static next-sync timestamp (`app/web/static/app.js`,
|
||||
`base.html`) into a live, client-ticking countdown.
|
||||
- Accessible focus states (visible lime outline) and `prefers-reduced-motion`
|
||||
handling for the one animated element (the countdown) and card entrance.
|
||||
|
||||
### Out of scope (separate follow-up specs)
|
||||
|
||||
- Dashboard summary/stat tiles (next phase, builds on this theme).
|
||||
- Live sync results without full page reload (UX-polish phase).
|
||||
- Any light-mode / theme-toggle support (explicitly rejected by user — dark
|
||||
only).
|
||||
- New charts/statistics views.
|
||||
- Any change to routes, models, or sync/business logic.
|
||||
|
||||
## 3. Design tokens
|
||||
|
||||
### Color
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| `--bg` | `#12151A` | Page background |
|
||||
| `--surface` | `#1A1F27` | Cards, topbar, table zebra-free rows |
|
||||
| `--surface-raised` | `#232935` | Hover states, inputs |
|
||||
| `--border` | `#2A3038` | Hairline dividers/card borders |
|
||||
| `--text` | `#E7EAF0` | Primary text |
|
||||
| `--text-muted` | `#8B93A3` | Secondary text, labels, hints |
|
||||
| `--accent` | `#C8FF4D` | Electric lime — signature countdown, primary buttons, focus rings, "healthy" status |
|
||||
|
||||
Semantic status colors (each with a ~14% opacity tint of the same hue over
|
||||
`--surface` for badge backgrounds, matching the existing `--*-bg` variable
|
||||
pattern already in `style.css`):
|
||||
|
||||
| State | Hex | Meaning |
|
||||
|---|---|---|
|
||||
| Success / healthy | `--accent` `#C8FF4D` | Reuses the signature accent — a healthy sync *is* the good state the accent celebrates |
|
||||
| Warning / degraded | `#FFB454` | Amber |
|
||||
| Danger / action required / failed | `#FF5F6D` | Coral |
|
||||
| Info / syncing / running | `#5FD4FF` | Cyan |
|
||||
| Neutral / disabled | `#5A6472` | Slate |
|
||||
|
||||
These map 1:1 onto the existing `--success`, `--warning`, `--danger`,
|
||||
`--info`, `--neutral` variable names already used by `.badge-*` classes in
|
||||
`style.css` — only their values change, not the class structure, so templates
|
||||
need no edits for badges.
|
||||
|
||||
### Typography
|
||||
|
||||
- Body/UI face: unchanged system stack (`-apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, Helvetica, Arial, sans-serif`) — no new font loads,
|
||||
works offline in a self-hosted container.
|
||||
- Monospace face for all numeric data (stats, timestamps, the countdown,
|
||||
table numeric columns): `ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||
"Liberation Mono", monospace`, with `font-variant-numeric: tabular-nums`.
|
||||
- Headings and structural labels (`h1`/`h2`, `.badge`, table `th`, the new
|
||||
topbar labels): uppercase, `letter-spacing: 0.06em`, extending the tracked
|
||||
uppercase style `table th` already has today — applied consistently instead
|
||||
of only in tables.
|
||||
|
||||
### Layout
|
||||
|
||||
- Topbar becomes the cockpit header: `--surface` background, hairline bottom
|
||||
border, brand mark left, nav + live countdown grouped right (existing
|
||||
`.topbar-right` wrapper from the next-sync work is reused).
|
||||
- Cards: hairline `--border` outline, `--surface` background, no drop shadow
|
||||
(shadows read poorly on dark; hairlines carry the "device bezel" feel
|
||||
instead). Radius stays at the existing `--radius: 10px`, unchanged.
|
||||
- Buttons: primary uses `--accent` background with dark text (for contrast
|
||||
against the light lime); secondary keeps outline/ghost style against
|
||||
`--surface`.
|
||||
- Focus-visible: 2px `--accent` outline on all interactive elements (links,
|
||||
buttons, inputs) — dark backgrounds need this to stay accessible since the
|
||||
current subtle browser default focus ring is hard to see on `--surface`.
|
||||
|
||||
### Motion
|
||||
|
||||
- The live countdown ticks once per second (text content update only, no
|
||||
layout shift).
|
||||
- Cards fade/slide in ~150ms on initial page load, `translateY(4px) → 0`.
|
||||
- Both respect `prefers-reduced-motion: reduce` (countdown text still updates,
|
||||
since it's informational, not decorative; the card entrance animation is
|
||||
skipped entirely).
|
||||
|
||||
## 4. Signature element: live sync countdown
|
||||
|
||||
`app/web/static/app.js` currently does a one-time UTC→local conversion of the
|
||||
`time[data-utc]` element on `DOMContentLoaded`. It's extended to:
|
||||
|
||||
1. On load, read `data-utc` as the target instant.
|
||||
2. If the target is in the future, start a `setInterval` (1s) that computes
|
||||
the remaining duration and renders it as `HH:MM:SS` (or `MM:SS` under an
|
||||
hour) in monospace, e.g. `NEXT SYNC ▸ 00:12:04`.
|
||||
3. If the target is in the past (page left open past the sync time, or
|
||||
`next_tick` not yet known on first boot), render `due now` instead of a
|
||||
negative countdown.
|
||||
4. The `title` attribute keeps showing the absolute local time (via
|
||||
`toLocaleString()`) and the original UTC instant, so hovering still gives
|
||||
an absolute reference — this preserves today's behavior as a fallback/aid.
|
||||
5. `data-utc` stays the templating contract between server and client (same
|
||||
attribute the current tests assert on), so no server-side route or test
|
||||
changes are needed for this evolution — only `app.js` behavior and the
|
||||
surrounding CSS/markup in `base.html` change.
|
||||
|
||||
No server-side change: `next_sync_tick()` in `app/web/routes.py` and the
|
||||
`base.html` template variable wiring stay as they are; only the visual
|
||||
presentation and `app.js` ticking logic change.
|
||||
|
||||
## 5. Testing
|
||||
|
||||
This is a CSS/JS-presentation change with one markup adjustment (countdown
|
||||
wrapper element/label in `base.html`). Existing server-rendered tests assert
|
||||
on `data-utc="..."` substrings and text content, not on CSS classes or exact
|
||||
visual output, so no test breakage is expected. No new automated test is
|
||||
meaningful for pure CSS token values; the existing
|
||||
`tests/web/test_next_sync_display.py` continues to guard the server-side
|
||||
contract (the attribute and value), and manual verification (screenshot) is
|
||||
used to confirm the visual outcome, consistent with how the next-sync feature
|
||||
was verified.
|
||||
|
||||
## 6. Rollout
|
||||
|
||||
Single pass across `style.css`, `base.html`, `app.js`. No template
|
||||
restructuring needed beyond `base.html`'s topbar, since all other templates
|
||||
already consume the shared `.card`, `.badge`, `button`/`.btn`, `table`, and
|
||||
`form.stacked-form` classes this spec restyles centrally.
|
||||
@@ -16,7 +16,7 @@ dependencies = [
|
||||
"python-multipart>=0.0.9,<1",
|
||||
"itsdangerous>=2.1,<3",
|
||||
"httpx>=0.27,<1",
|
||||
"garminconnect>=0.2,<1",
|
||||
"garminconnect>=0.3.10,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
108
tests/auth/test_account.py
Normal file
108
tests/auth/test_account.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.auth.account import authenticate_self_service
|
||||
from app.db.models import Base
|
||||
from app.db.repositories import UserRepository
|
||||
from app.security.credentials import CredentialCipher
|
||||
|
||||
|
||||
def _make_session_and_cipher():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
cipher = CredentialCipher(Fernet.generate_key().decode("ascii"))
|
||||
return factory(), cipher
|
||||
|
||||
|
||||
def _seed_user(session, cipher, **overrides):
|
||||
values = dict(
|
||||
name="Max",
|
||||
enabled=True,
|
||||
mywhoosh_email_enc=cipher.encrypt("max@mywhoosh.example"),
|
||||
mywhoosh_password_enc=cipher.encrypt("mw-secret"),
|
||||
garmin_email_enc=cipher.encrypt("max@garmin.example"),
|
||||
garmin_password_enc=cipher.encrypt("garmin-secret"),
|
||||
)
|
||||
values.update(overrides)
|
||||
return UserRepository(session).create(**values)
|
||||
|
||||
|
||||
def test_authenticates_with_mywhoosh_credentials() -> None:
|
||||
session, cipher = _make_session_and_cipher()
|
||||
user = _seed_user(session, cipher)
|
||||
|
||||
result = authenticate_self_service(session, cipher, "max@mywhoosh.example", "mw-secret")
|
||||
|
||||
assert result is not None
|
||||
assert result.id == user.id
|
||||
|
||||
|
||||
def test_authenticates_with_garmin_credentials() -> None:
|
||||
session, cipher = _make_session_and_cipher()
|
||||
user = _seed_user(session, cipher)
|
||||
|
||||
result = authenticate_self_service(session, cipher, "max@garmin.example", "garmin-secret")
|
||||
|
||||
assert result is not None
|
||||
assert result.id == user.id
|
||||
|
||||
|
||||
def test_rejects_wrong_password() -> None:
|
||||
session, cipher = _make_session_and_cipher()
|
||||
_seed_user(session, cipher)
|
||||
|
||||
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "wrong") is None
|
||||
|
||||
|
||||
def test_rejects_unknown_email() -> None:
|
||||
session, cipher = _make_session_and_cipher()
|
||||
_seed_user(session, cipher)
|
||||
|
||||
assert authenticate_self_service(session, cipher, "nobody@example.com", "mw-secret") is None
|
||||
|
||||
|
||||
def test_rejects_mixed_email_and_password_from_different_accounts() -> None:
|
||||
"""A MyWhoosh email paired with the Garmin password (or vice versa) for
|
||||
the same user must not authenticate -- each pair is checked together."""
|
||||
session, cipher = _make_session_and_cipher()
|
||||
_seed_user(session, cipher)
|
||||
|
||||
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "garmin-secret") is None
|
||||
assert authenticate_self_service(session, cipher, "max@garmin.example", "mw-secret") is None
|
||||
|
||||
|
||||
def test_rejects_empty_password() -> None:
|
||||
session, cipher = _make_session_and_cipher()
|
||||
_seed_user(session, cipher)
|
||||
|
||||
assert authenticate_self_service(session, cipher, "max@mywhoosh.example", "") is None
|
||||
|
||||
|
||||
def test_picks_correct_user_among_several() -> None:
|
||||
session, cipher = _make_session_and_cipher()
|
||||
_seed_user(
|
||||
session,
|
||||
cipher,
|
||||
name="Anna",
|
||||
mywhoosh_email_enc=cipher.encrypt("anna@mywhoosh.example"),
|
||||
mywhoosh_password_enc=cipher.encrypt("anna-secret"),
|
||||
garmin_email_enc=cipher.encrypt("anna@garmin.example"),
|
||||
garmin_password_enc=cipher.encrypt("anna-garmin-secret"),
|
||||
)
|
||||
bob = _seed_user(
|
||||
session,
|
||||
cipher,
|
||||
name="Bob",
|
||||
mywhoosh_email_enc=cipher.encrypt("bob@mywhoosh.example"),
|
||||
mywhoosh_password_enc=cipher.encrypt("bob-secret"),
|
||||
garmin_email_enc=cipher.encrypt("bob@garmin.example"),
|
||||
garmin_password_enc=cipher.encrypt("bob-garmin-secret"),
|
||||
)
|
||||
|
||||
result = authenticate_self_service(session, cipher, "bob@mywhoosh.example", "bob-secret")
|
||||
|
||||
assert result is not None
|
||||
assert result.id == bob.id
|
||||
@@ -8,8 +8,14 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.models import Base
|
||||
from app.db.repositories import ActivityRepository, UserRepository
|
||||
from app.db.models import Activity, Base, HealthState
|
||||
from app.db.repositories import (
|
||||
ActivityRepository,
|
||||
SchedulerSettingsRepository,
|
||||
SyncRunRepository,
|
||||
SystemLogRepository,
|
||||
UserRepository,
|
||||
)
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@@ -40,7 +46,22 @@ def activity_repository(db_session: Session) -> ActivityRepository:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path) -> TestClient:
|
||||
def sync_run_repository(db_session: Session) -> SyncRunRepository:
|
||||
return SyncRunRepository(db_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler_settings_repository(db_session: Session) -> SchedulerSettingsRepository:
|
||||
return SchedulerSettingsRepository(db_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def system_log_repository(db_session: Session) -> SystemLogRepository:
|
||||
return SystemLogRepository(db_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(tmp_path: Path):
|
||||
settings = Settings(
|
||||
ADMIN_PASSWORD="admin-secret",
|
||||
SECRET_KEY="0123456789abcdef0123456789abcdef",
|
||||
@@ -49,8 +70,80 @@ 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
|
||||
def seeded_activity(db_session: Session, user_repository: UserRepository, activity_repository: ActivityRepository) -> Activity:
|
||||
user = user_repository.create(
|
||||
name="Test User",
|
||||
enabled=True,
|
||||
health_state=HealthState.HEALTHY,
|
||||
mywhoosh_email_enc="test@example.com",
|
||||
mywhoosh_password_enc="password",
|
||||
garmin_email_enc="test@garmin.com",
|
||||
garmin_password_enc="garmin_password",
|
||||
)
|
||||
activity, _ = activity_repository.get_or_create_discovered(
|
||||
user_id=user.id,
|
||||
mywhoosh_activity_id="mw-test-123",
|
||||
activity_name="Test Activity",
|
||||
activity_timestamp=None,
|
||||
)
|
||||
return activity
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from app.db.models import ActivityStatus, HealthState
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.db.models import ActivityStatus, HealthState, SyncRunStatus
|
||||
|
||||
|
||||
def test_create_two_independent_users(db_session, user_repository) -> None:
|
||||
@@ -64,3 +66,160 @@ def test_activity_external_id_is_unique_per_user(user_repository, activity_repos
|
||||
assert inserted_again is False
|
||||
assert created.id == same.id
|
||||
assert same.status == ActivityStatus.DISCOVERED
|
||||
|
||||
|
||||
def test_system_log_lists_most_recent_first(system_log_repository, user_repository) -> None:
|
||||
user = user_repository.create(
|
||||
name="Max",
|
||||
enabled=True,
|
||||
health_state=HealthState.HEALTHY,
|
||||
mywhoosh_email_enc="mw-1",
|
||||
mywhoosh_password_enc="mw-pw-1",
|
||||
garmin_email_enc="g-1",
|
||||
garmin_password_enc="g-pw-1",
|
||||
)
|
||||
system_log_repository.add(source="email_notification", message="first failure", user_id=user.id)
|
||||
system_log_repository.add(source="email_notification", message="second failure", user_id=user.id)
|
||||
|
||||
entries = system_log_repository.list_recent()
|
||||
|
||||
assert [entry.message for entry in entries] == ["second failure", "first failure"]
|
||||
assert entries[0].source == "email_notification"
|
||||
assert entries[0].user_id == user.id
|
||||
|
||||
|
||||
def test_system_log_respects_limit(system_log_repository) -> None:
|
||||
for i in range(5):
|
||||
system_log_repository.add(source="test", message=f"entry {i}")
|
||||
|
||||
entries = system_log_repository.list_recent(limit=2)
|
||||
|
||||
assert len(entries) == 2
|
||||
|
||||
|
||||
def test_scheduler_settings_get_or_create_seeds_defaults(scheduler_settings_repository) -> None:
|
||||
row = scheduler_settings_repository.get_or_create(default_minutes=7)
|
||||
|
||||
assert row.day_interval_minutes == 7
|
||||
assert row.night_interval_minutes == 7
|
||||
assert row.day_start_hour == 6
|
||||
assert row.night_start_hour == 22
|
||||
|
||||
|
||||
def test_scheduler_settings_get_or_create_is_idempotent_after_update(scheduler_settings_repository) -> None:
|
||||
row = scheduler_settings_repository.get_or_create(default_minutes=5)
|
||||
scheduler_settings_repository.update(row, day_interval_minutes=15)
|
||||
|
||||
reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
|
||||
|
||||
assert reloaded.day_interval_minutes == 15
|
||||
|
||||
|
||||
def test_scheduler_settings_update_persists_all_fields(scheduler_settings_repository) -> None:
|
||||
row = scheduler_settings_repository.get_or_create(default_minutes=5)
|
||||
|
||||
scheduler_settings_repository.update(
|
||||
row,
|
||||
day_start_hour=8,
|
||||
night_start_hour=20,
|
||||
day_interval_minutes=10,
|
||||
night_interval_minutes=45,
|
||||
)
|
||||
|
||||
reloaded = scheduler_settings_repository.get_or_create(default_minutes=5)
|
||||
assert reloaded.day_start_hour == 8
|
||||
assert reloaded.night_start_hour == 20
|
||||
assert reloaded.day_interval_minutes == 10
|
||||
assert reloaded.night_interval_minutes == 45
|
||||
|
||||
|
||||
def _make_user(repo, name, *, enabled=True, health_state=HealthState.HEALTHY):
|
||||
return repo.create(
|
||||
name=name,
|
||||
enabled=enabled,
|
||||
health_state=health_state,
|
||||
mywhoosh_email_enc=f"mw-{name}",
|
||||
mywhoosh_password_enc=f"mw-pw-{name}",
|
||||
garmin_email_enc=f"g-{name}",
|
||||
garmin_password_enc=f"g-pw-{name}",
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_summary_counts_riders_total_and_enabled(user_repository) -> None:
|
||||
_make_user(user_repository, "Alex", enabled=True)
|
||||
_make_user(user_repository, "Jamie", enabled=True)
|
||||
_make_user(user_repository, "Paused", enabled=False)
|
||||
|
||||
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
|
||||
|
||||
assert summary.rider_total == 3
|
||||
assert summary.rider_enabled == 2
|
||||
|
||||
|
||||
def test_dashboard_summary_counts_action_required_riders(user_repository) -> None:
|
||||
_make_user(user_repository, "Healthy", health_state=HealthState.HEALTHY)
|
||||
_make_user(user_repository, "Blocked", health_state=HealthState.ACTION_REQUIRED)
|
||||
_make_user(user_repository, "AlsoBlocked", health_state=HealthState.ACTION_REQUIRED)
|
||||
|
||||
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
|
||||
|
||||
assert summary.action_required_count == 2
|
||||
|
||||
|
||||
def test_dashboard_summary_sums_imported_count_within_window_only(user_repository, sync_run_repository) -> None:
|
||||
user = _make_user(user_repository, "Alex")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
recent_run = sync_run_repository.start(user.id)
|
||||
recent_run.started_at = now - timedelta(days=1)
|
||||
sync_run_repository.finish(
|
||||
recent_run.id, status=SyncRunStatus.SUCCESS, discovered=5, imported=5, skipped=0, failed=0
|
||||
)
|
||||
|
||||
old_run = sync_run_repository.start(user.id)
|
||||
old_run.started_at = now - timedelta(days=30)
|
||||
sync_run_repository.finish(
|
||||
old_run.id, status=SyncRunStatus.SUCCESS, discovered=3, imported=3, skipped=0, failed=0
|
||||
)
|
||||
|
||||
summary = user_repository.dashboard_summary(since=now - timedelta(days=7))
|
||||
|
||||
assert summary.imported_recent == 5
|
||||
|
||||
|
||||
def test_dashboard_summary_computes_success_rate_from_finished_runs_in_window(
|
||||
user_repository, sync_run_repository
|
||||
) -> None:
|
||||
user = _make_user(user_repository, "Alex")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL, SyncRunStatus.FAILED, SyncRunStatus.FAILED):
|
||||
run = sync_run_repository.start(user.id)
|
||||
run.started_at = now - timedelta(hours=1)
|
||||
sync_run_repository.finish(run.id, status=status, discovered=1, imported=0, skipped=0, failed=1)
|
||||
|
||||
summary = user_repository.dashboard_summary(since=now - timedelta(days=7))
|
||||
|
||||
assert summary.success_rate_recent == 50.0
|
||||
|
||||
|
||||
def test_dashboard_summary_success_rate_is_none_without_finished_runs_in_window(user_repository) -> None:
|
||||
summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7))
|
||||
|
||||
assert summary.success_rate_recent is None
|
||||
|
||||
|
||||
def test_dashboard_row_returns_row_for_known_user(user_repository) -> None:
|
||||
user = _make_user(user_repository, "Alex")
|
||||
|
||||
row = user_repository.dashboard_row(user.id)
|
||||
|
||||
assert row is not None
|
||||
assert row.id == user.id
|
||||
assert row.name == "Alex"
|
||||
|
||||
|
||||
def test_dashboard_row_returns_none_for_unknown_user(user_repository) -> None:
|
||||
row = user_repository.dashboard_row(999)
|
||||
|
||||
assert row is None
|
||||
|
||||
109
tests/db/test_session.py
Normal file
109
tests/db/test_session.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db.session import initialize_schema
|
||||
|
||||
|
||||
def test_initialize_schema_creates_fresh_database() -> None:
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
try:
|
||||
initialize_schema(engine)
|
||||
columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
|
||||
assert "notify_email_enabled" in columns
|
||||
assert "notification_email" in columns
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_initialize_schema_adds_missing_columns_without_dropping_existing_rows() -> None:
|
||||
"""Regression test: a database created before notify_email_enabled/
|
||||
notification_email existed must gain those columns in place, keeping
|
||||
every already-stored user row intact -- create_all() alone would not add
|
||||
columns to a table that already exists."""
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
try:
|
||||
# Simulate the pre-existing production schema by creating every
|
||||
# table via the current models, then dropping the two new columns
|
||||
# back off sync_users the only way sqlite allows: rebuild the table.
|
||||
Base.metadata.create_all(engine)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("ALTER TABLE sync_users RENAME TO sync_users_old"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE sync_users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
health_state VARCHAR NOT NULL,
|
||||
mywhoosh_state VARCHAR(32) NOT NULL,
|
||||
garmin_state VARCHAR(32) NOT NULL,
|
||||
action_reason TEXT,
|
||||
mywhoosh_email_enc TEXT NOT NULL,
|
||||
mywhoosh_password_enc TEXT NOT NULL,
|
||||
garmin_email_enc TEXT NOT NULL,
|
||||
garmin_password_enc TEXT NOT NULL,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sync_users (
|
||||
id, name, enabled, health_state, mywhoosh_state, garmin_state,
|
||||
action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
|
||||
garmin_email_enc, garmin_password_enc, created_at, updated_at
|
||||
)
|
||||
SELECT id, name, enabled, health_state, mywhoosh_state, garmin_state,
|
||||
action_reason, mywhoosh_email_enc, mywhoosh_password_enc,
|
||||
garmin_email_enc, garmin_password_enc, created_at, updated_at
|
||||
FROM sync_users_old
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(text("DROP TABLE sync_users_old"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sync_users (
|
||||
id, name, enabled, health_state, mywhoosh_state, garmin_state,
|
||||
mywhoosh_email_enc, mywhoosh_password_enc, garmin_email_enc, garmin_password_enc
|
||||
) VALUES (
|
||||
1, 'Existing User', 1, 'healthy', 'connected', 'connected',
|
||||
'enc-mw-email', 'enc-mw-pass', 'enc-garmin-email', 'enc-garmin-pass'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
columns_before = {col["name"] for col in inspect(engine).get_columns("sync_users")}
|
||||
assert "notify_email_enabled" not in columns_before
|
||||
|
||||
initialize_schema(engine)
|
||||
|
||||
columns_after = {col["name"] for col in inspect(engine).get_columns("sync_users")}
|
||||
assert "notify_email_enabled" in columns_after
|
||||
assert "notification_email" in columns_after
|
||||
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(text("SELECT name, notify_email_enabled, notification_email FROM sync_users")).one()
|
||||
assert row.name == "Existing User"
|
||||
assert row.notify_email_enabled == 0
|
||||
assert row.notification_email is None
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_initialize_schema_is_idempotent() -> None:
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
try:
|
||||
initialize_schema(engine)
|
||||
initialize_schema(engine)
|
||||
columns = {col["name"] for col in inspect(engine).get_columns("sync_users")}
|
||||
assert "notify_email_enabled" in columns
|
||||
finally:
|
||||
engine.dispose()
|
||||
132
tests/db/test_sync_state.py
Normal file
132
tests/db/test_sync_state.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from app.db.models import ActivityStatus, SyncRunStatus
|
||||
|
||||
|
||||
def test_failure_retains_last_completed_stage(activity_repository, seeded_activity) -> None:
|
||||
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
|
||||
activity_repository.mark_failed(seeded_activity.id, "Garmin timeout", retryable=True)
|
||||
activity = activity_repository.get(seeded_activity.id)
|
||||
|
||||
assert activity.status == ActivityStatus.FAILED
|
||||
assert activity.last_completed_stage == ActivityStatus.DOWNLOADED
|
||||
assert activity.retryable is True
|
||||
|
||||
|
||||
def test_converted_activity_is_pending_until_terminal(activity_repository, seeded_activity) -> None:
|
||||
activity_repository.mark_converted(seeded_activity.id, "/data/activities/1/a/converted.fit")
|
||||
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
|
||||
assert seeded_activity.id in ids
|
||||
|
||||
|
||||
def test_list_pending_excludes_imported(activity_repository, seeded_activity) -> None:
|
||||
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
|
||||
activity_repository.mark_converted(seeded_activity.id, "/data/activities/1/a/converted.fit")
|
||||
activity_repository.mark_imported(seeded_activity.id, "garmin-123")
|
||||
|
||||
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
|
||||
assert seeded_activity.id not in ids
|
||||
|
||||
|
||||
def test_list_pending_excludes_duplicate(activity_repository, seeded_activity) -> None:
|
||||
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
|
||||
activity_repository.mark_duplicate(seeded_activity.id)
|
||||
|
||||
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
|
||||
assert seeded_activity.id not in ids
|
||||
|
||||
|
||||
def test_list_pending_excludes_non_retryable_failed(activity_repository, seeded_activity) -> None:
|
||||
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
|
||||
activity_repository.mark_failed(seeded_activity.id, "Cannot retry", retryable=False)
|
||||
|
||||
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
|
||||
assert seeded_activity.id not in ids
|
||||
|
||||
|
||||
def test_list_pending_includes_retryable_failed(activity_repository, seeded_activity) -> None:
|
||||
activity_repository.mark_downloaded(seeded_activity.id, "/data/activities/1/a/source.fit")
|
||||
activity_repository.mark_failed(seeded_activity.id, "Temporary error", retryable=True)
|
||||
|
||||
ids = [item.id for item in activity_repository.list_pending_for_user(seeded_activity.user_id)]
|
||||
assert seeded_activity.id in ids
|
||||
|
||||
|
||||
def test_sync_run_start_and_finish(sync_run_repository, user_repository) -> None:
|
||||
user = user_repository.create(
|
||||
name="Test User",
|
||||
enabled=True,
|
||||
health_state="healthy",
|
||||
mywhoosh_email_enc="test@example.com",
|
||||
mywhoosh_password_enc="password",
|
||||
garmin_email_enc="test@garmin.com",
|
||||
garmin_password_enc="garmin_password",
|
||||
)
|
||||
|
||||
sync_run = sync_run_repository.start(user.id)
|
||||
assert sync_run.status == SyncRunStatus.RUNNING
|
||||
assert sync_run.discovered_count == 0
|
||||
assert sync_run.imported_count == 0
|
||||
assert sync_run.skipped_count == 0
|
||||
assert sync_run.failed_count == 0
|
||||
|
||||
finished = sync_run_repository.finish(
|
||||
sync_run.id,
|
||||
status=SyncRunStatus.SUCCESS,
|
||||
discovered=5,
|
||||
imported=3,
|
||||
skipped=1,
|
||||
failed=1,
|
||||
)
|
||||
|
||||
assert finished.status == SyncRunStatus.SUCCESS
|
||||
assert finished.discovered_count == 5
|
||||
assert finished.imported_count == 3
|
||||
assert finished.skipped_count == 1
|
||||
assert finished.failed_count == 1
|
||||
assert finished.finished_at is not None
|
||||
|
||||
# Reload from DB to verify persisted
|
||||
reloaded = sync_run_repository.get(sync_run.id)
|
||||
assert reloaded.status == SyncRunStatus.SUCCESS
|
||||
assert reloaded.discovered_count == 5
|
||||
assert reloaded.imported_count == 3
|
||||
|
||||
|
||||
def test_sync_run_finish_with_error(sync_run_repository, user_repository) -> None:
|
||||
user = user_repository.create(
|
||||
name="Test User",
|
||||
enabled=True,
|
||||
health_state="healthy",
|
||||
mywhoosh_email_enc="test@example.com",
|
||||
mywhoosh_password_enc="password",
|
||||
garmin_email_enc="test@garmin.com",
|
||||
garmin_password_enc="garmin_password",
|
||||
)
|
||||
|
||||
sync_run = sync_run_repository.start(user.id)
|
||||
error_msg = "Connection timeout"
|
||||
|
||||
finished = sync_run_repository.finish(
|
||||
sync_run.id,
|
||||
status=SyncRunStatus.FAILED,
|
||||
discovered=0,
|
||||
imported=0,
|
||||
skipped=0,
|
||||
failed=0,
|
||||
summary_error=error_msg,
|
||||
)
|
||||
|
||||
assert finished.summary_error == error_msg
|
||||
|
||||
# Verify truncation works
|
||||
long_error = "x" * 5000
|
||||
finished_long = sync_run_repository.finish(
|
||||
sync_run.id,
|
||||
status=SyncRunStatus.FAILED,
|
||||
discovered=0,
|
||||
imported=0,
|
||||
skipped=0,
|
||||
failed=0,
|
||||
summary_error=long_error,
|
||||
)
|
||||
assert len(finished_long.summary_error) == 2000
|
||||
assert finished_long.summary_error == long_error[:2000]
|
||||
@@ -2,7 +2,13 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.garmin.uploader import GarminUploadBlocked, GarminUploader
|
||||
from app.garmin.uploader import (
|
||||
GarminAuthError,
|
||||
GarminImportRejected,
|
||||
GarminTransientError,
|
||||
GarminUploadBlocked,
|
||||
GarminUploader,
|
||||
)
|
||||
|
||||
|
||||
class FakeGarmin:
|
||||
@@ -78,3 +84,93 @@ def test_mfa_code_is_returned_only_to_prompt(tmp_path: Path) -> None:
|
||||
)
|
||||
uploader.import_fit(tmp_path / "ride.fit", mfa_code="123456")
|
||||
assert seen == ["123456"]
|
||||
|
||||
|
||||
def test_import_rejected_by_garmin_raises_garmin_import_rejected(tmp_path: Path) -> None:
|
||||
rejected_response = {
|
||||
"detailedImportResult": {
|
||||
"successes": [],
|
||||
"failures": [{"internalId": 1, "messages": ["Invalid FIT file"]}],
|
||||
}
|
||||
}
|
||||
uploader = GarminUploader(
|
||||
email="g@example.com",
|
||||
password="pw",
|
||||
tokenstore=tmp_path / "garmin",
|
||||
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, import_result=rejected_response),
|
||||
)
|
||||
with pytest.raises(GarminImportRejected):
|
||||
uploader.import_fit(tmp_path / "ride.fit")
|
||||
|
||||
|
||||
def test_login_failure_with_429_is_transient(tmp_path: Path) -> None:
|
||||
uploader = GarminUploader(
|
||||
email="g@example.com",
|
||||
password="pw",
|
||||
tokenstore=tmp_path / "garmin",
|
||||
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, login_error=RuntimeError("429 too many requests")),
|
||||
)
|
||||
with pytest.raises(GarminTransientError):
|
||||
uploader.import_fit(tmp_path / "ride.fit")
|
||||
|
||||
|
||||
def test_login_failure_with_timeout_is_transient(tmp_path: Path) -> None:
|
||||
uploader = GarminUploader(
|
||||
email="g@example.com",
|
||||
password="pw",
|
||||
tokenstore=tmp_path / "garmin",
|
||||
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, login_error=RuntimeError("connection timeout")),
|
||||
)
|
||||
with pytest.raises(GarminTransientError):
|
||||
uploader.import_fit(tmp_path / "ride.fit")
|
||||
|
||||
|
||||
def test_login_failure_with_credential_message_is_auth_error(tmp_path: Path) -> None:
|
||||
uploader = GarminUploader(
|
||||
email="g@example.com",
|
||||
password="pw",
|
||||
tokenstore=tmp_path / "garmin",
|
||||
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, login_error=RuntimeError("invalid credential")),
|
||||
)
|
||||
with pytest.raises(GarminAuthError):
|
||||
uploader.import_fit(tmp_path / "ride.fit")
|
||||
|
||||
|
||||
def test_login_failure_unrecognized_is_transient(tmp_path: Path) -> None:
|
||||
uploader = GarminUploader(
|
||||
email="g@example.com",
|
||||
password="pw",
|
||||
tokenstore=tmp_path / "garmin",
|
||||
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, login_error=RuntimeError("something odd happened")),
|
||||
)
|
||||
with pytest.raises(GarminTransientError):
|
||||
uploader.import_fit(tmp_path / "ride.fit")
|
||||
|
||||
|
||||
def test_import_time_401_raises_auth_error(tmp_path: Path) -> None:
|
||||
uploader = GarminUploader(
|
||||
email="g@example.com",
|
||||
password="pw",
|
||||
tokenstore=tmp_path / "garmin",
|
||||
client_factory=lambda *a, **kw: FakeGarmin(*a, **kw, import_error=RuntimeError("401 unauthorized")),
|
||||
)
|
||||
with pytest.raises(GarminAuthError):
|
||||
uploader.import_fit(tmp_path / "ride.fit")
|
||||
|
||||
|
||||
def test_mfa_wrapped_in_generic_exception_still_blocks(tmp_path: Path) -> None:
|
||||
class WrappedMfaGarmin(FakeGarmin):
|
||||
def login(self, tokenstore=None):
|
||||
try:
|
||||
self.prompt_mfa()
|
||||
except GarminUploadBlocked as exc:
|
||||
raise RuntimeError(f"Login failed: Garmin requested MFA ({exc})") from exc
|
||||
|
||||
uploader = GarminUploader(
|
||||
email="g@example.com",
|
||||
password="pw",
|
||||
tokenstore=tmp_path / "garmin",
|
||||
client_factory=WrappedMfaGarmin,
|
||||
)
|
||||
with pytest.raises(GarminUploadBlocked):
|
||||
uploader.import_fit(tmp_path / "ride.fit")
|
||||
|
||||
@@ -134,7 +134,7 @@ async def test_download_fit_fetches_signed_url_bytes(tmp_path) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_activities_raises_integration_error_on_missing_stable_id(tmp_path) -> None:
|
||||
async def test_list_activities_skips_row_missing_stable_id(tmp_path) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/activities"):
|
||||
return httpx.Response(
|
||||
@@ -147,7 +147,13 @@ async def test_list_activities_raises_integration_error_on_missing_stable_id(tmp
|
||||
"title": "Ride without id",
|
||||
"activityFileId": "f-1",
|
||||
"startDatetime": "2026-08-15T06:00:00.000Z",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a-2",
|
||||
"title": "Ride with id",
|
||||
"activityFileId": "f-2",
|
||||
"startDatetime": "2026-08-15T06:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
@@ -158,5 +164,108 @@ async def test_list_activities_raises_integration_error_on_missing_stable_id(tmp
|
||||
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
|
||||
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
activities = await client.list_activities("rider@example.com", "secret")
|
||||
|
||||
assert [a.id for a in activities] == ["a-2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_activities_skips_row_with_unparseable_start_datetime(tmp_path) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/activities"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": {
|
||||
"totalPages": 1,
|
||||
"results": [
|
||||
{
|
||||
"id": "a-1",
|
||||
"title": "Ride with bad date",
|
||||
"activityFileId": "f-1",
|
||||
"startDatetime": "not-a-date",
|
||||
},
|
||||
{
|
||||
"id": "a-2",
|
||||
"title": "Ride with good date",
|
||||
"activityFileId": "f-2",
|
||||
"startDatetime": "2026-08-15T06:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
raise AssertionError(request.url)
|
||||
|
||||
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
|
||||
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
|
||||
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
activities = await client.list_activities("rider@example.com", "secret")
|
||||
|
||||
assert [a.id for a in activities] == ["a-2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_activities_raises_integration_error_on_envelope_shape_failure(tmp_path) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/activities"):
|
||||
return httpx.Response(200, json={"data": {"totalPages": 1}})
|
||||
raise AssertionError(request.url)
|
||||
|
||||
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
|
||||
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
|
||||
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
with pytest.raises(MyWhooshIntegrationError):
|
||||
await client.list_activities("rider@example.com", "secret")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_activities_respects_max_pages(tmp_path) -> None:
|
||||
calls = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/activities"):
|
||||
payload = json.loads(request.content)
|
||||
page = payload["page"]
|
||||
calls.append(page)
|
||||
result = {
|
||||
"data": {
|
||||
"totalPages": 5,
|
||||
"results": [
|
||||
{
|
||||
"id": f"a-{page}",
|
||||
"title": f"Ride {page}",
|
||||
"activityFileId": f"f-{page}",
|
||||
"startDatetime": "2026-08-15T06:00:00.000Z",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
return httpx.Response(200, json=result)
|
||||
raise AssertionError(request.url)
|
||||
|
||||
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
|
||||
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
|
||||
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
activities = await client.list_activities("rider@example.com", "secret", max_pages=1)
|
||||
|
||||
assert calls == [1]
|
||||
assert [a.id for a in activities] == ["a-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_fit_raises_integration_error_on_invalid_json(tmp_path) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/download-activity-file"):
|
||||
return httpx.Response(200, content=b"not json", headers={"content-type": "application/json"})
|
||||
raise AssertionError(request.url)
|
||||
|
||||
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
|
||||
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
|
||||
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
with pytest.raises(MyWhooshIntegrationError):
|
||||
await client.download_fit("f-1", "rider@example.com", "secret")
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.mywhoosh.client import MyWhooshClient, MyWhooshAuthError
|
||||
from app.mywhoosh.client import (
|
||||
MyWhooshAuthError,
|
||||
MyWhooshClient,
|
||||
MyWhooshDeviceConflictError,
|
||||
MyWhooshIntegrationError,
|
||||
MyWhooshTransientError,
|
||||
)
|
||||
from app.mywhoosh.models import MyWhooshToken
|
||||
from app.mywhoosh.tokenstore import MyWhooshTokenStore
|
||||
|
||||
@@ -37,3 +45,129 @@ async def test_invalid_credentials_raise_auth_error(tmp_path) -> None:
|
||||
)
|
||||
with pytest.raises(MyWhooshAuthError):
|
||||
await client.login("rider@example.com", "bad")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_conflict_message_raises_device_conflict_error(tmp_path) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200, json={"Success": False, "Message": "You are already logged in from another device."}
|
||||
)
|
||||
|
||||
client = MyWhooshClient(
|
||||
MyWhooshTokenStore(tmp_path / "mywhoosh.json"),
|
||||
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
with pytest.raises(MyWhooshDeviceConflictError):
|
||||
await client.login("rider@example.com", "secret")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_reuses_stable_device_id_across_calls(tmp_path) -> None:
|
||||
seen_device_ids = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_device_ids.append(json.loads(request.content)["DeviceId"])
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"Success": True, "AccessToken": "access", "RefreshToken": "refresh", "WhooshId": "w-1"},
|
||||
)
|
||||
|
||||
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
|
||||
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
await client.login("rider@example.com", "secret")
|
||||
await client.login("rider@example.com", "secret")
|
||||
|
||||
assert len(seen_device_ids) == 2
|
||||
assert seen_device_ids[0] == seen_device_ids[1]
|
||||
assert seen_device_ids[0] == store.get_or_create_device_id()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_returns_json_array_raises_integration_error(tmp_path) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=["not", "an", "object"])
|
||||
|
||||
client = MyWhooshClient(
|
||||
MyWhooshTokenStore(tmp_path / "mywhoosh.json"),
|
||||
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
with pytest.raises(MyWhooshIntegrationError):
|
||||
await client.login("rider@example.com", "bad")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_5xx_raises_transient_error(tmp_path) -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(503, text="Service Unavailable")
|
||||
|
||||
client = MyWhooshClient(
|
||||
MyWhooshTokenStore(tmp_path / "mywhoosh.json"),
|
||||
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
with pytest.raises(MyWhooshTransientError):
|
||||
await client.login("rider@example.com", "secret")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_401_after_reauth_raises_auth_error(tmp_path) -> None:
|
||||
login_call_count = 0
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal login_call_count
|
||||
if request.url.path.endswith("/activities"):
|
||||
return httpx.Response(401, json={"message": "expired"})
|
||||
if request.url.path.endswith("/login"):
|
||||
login_call_count += 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"Success": True,
|
||||
"AccessToken": "fresh-access",
|
||||
"RefreshToken": "fresh-refresh",
|
||||
"WhooshId": "w-1",
|
||||
},
|
||||
)
|
||||
raise AssertionError(request.url)
|
||||
|
||||
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
|
||||
store.save(MyWhooshToken(access_token="stale", refresh_token=None, whoosh_id=None))
|
||||
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
with pytest.raises(MyWhooshAuthError):
|
||||
await client.list_activities("rider@example.com", "secret")
|
||||
|
||||
assert login_call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_closes_self_owned_http_client(tmp_path) -> None:
|
||||
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
|
||||
client = MyWhooshClient(store)
|
||||
|
||||
await client.aclose()
|
||||
|
||||
assert client.http.is_closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_does_not_close_injected_http_client(tmp_path) -> None:
|
||||
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
|
||||
injected = httpx.AsyncClient()
|
||||
client = MyWhooshClient(store, http_client=injected)
|
||||
|
||||
await client.aclose()
|
||||
|
||||
assert injected.is_closed is False
|
||||
await injected.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_usable_as_async_context_manager(tmp_path) -> None:
|
||||
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
|
||||
|
||||
async with MyWhooshClient(store) as client:
|
||||
http_client = client.http
|
||||
assert http_client.is_closed is False
|
||||
|
||||
assert http_client.is_closed is True
|
||||
|
||||
@@ -16,3 +16,39 @@ def test_tokenstore_round_trip_and_permissions(tmp_path: Path) -> None:
|
||||
def test_missing_token_returns_none(tmp_path: Path) -> None:
|
||||
store = MyWhooshTokenStore(tmp_path / "missing.json")
|
||||
assert store.load() is None
|
||||
|
||||
|
||||
def test_corrupt_token_file_returns_none(tmp_path: Path) -> None:
|
||||
path = tmp_path / "mywhoosh.json"
|
||||
path.write_bytes(b"not valid json {{{")
|
||||
store = MyWhooshTokenStore(path)
|
||||
assert store.load() is None
|
||||
|
||||
|
||||
def test_token_file_missing_access_token_returns_none(tmp_path: Path) -> None:
|
||||
path = tmp_path / "mywhoosh.json"
|
||||
path.write_text('{"refresh_token": "r", "whoosh_id": "w"}', encoding="utf-8")
|
||||
store = MyWhooshTokenStore(path)
|
||||
assert store.load() is None
|
||||
|
||||
|
||||
def test_clear_removes_token_and_load_returns_none(tmp_path: Path) -> None:
|
||||
store = MyWhooshTokenStore(tmp_path / "tokens" / "mywhoosh.json")
|
||||
token = MyWhooshToken(access_token="access", refresh_token="refresh", whoosh_id="whoosh-1")
|
||||
store.save(token)
|
||||
|
||||
store.clear()
|
||||
|
||||
assert store.load() is None
|
||||
assert not store.path.exists()
|
||||
|
||||
|
||||
def test_get_or_create_device_id_persists_and_is_stable(tmp_path: Path) -> None:
|
||||
path = tmp_path / "tokens" / "7" / "mywhoosh.json"
|
||||
device_id = MyWhooshTokenStore(path).get_or_create_device_id()
|
||||
|
||||
reloaded_id = MyWhooshTokenStore(path).get_or_create_device_id()
|
||||
|
||||
assert reloaded_id == device_id
|
||||
device_id_path = path.with_name("device_id")
|
||||
assert device_id_path.read_text("utf-8").strip() == device_id
|
||||
|
||||
69
tests/notifications/test_emailer.py
Normal file
69
tests/notifications/test_emailer.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.notifications.emailer import EmailNotifier
|
||||
|
||||
|
||||
def _notifier(**overrides) -> EmailNotifier:
|
||||
defaults = dict(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
username="user@example.com",
|
||||
password="secret",
|
||||
from_address="sync@example.com",
|
||||
use_tls=True,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return EmailNotifier(**defaults)
|
||||
|
||||
|
||||
def test_unconfigured_notifier_is_not_configured() -> None:
|
||||
notifier = _notifier(host=None)
|
||||
assert notifier.configured is False
|
||||
|
||||
|
||||
def test_configured_notifier_is_configured() -> None:
|
||||
assert _notifier().configured is True
|
||||
|
||||
|
||||
def test_send_skips_silently_when_not_configured() -> None:
|
||||
notifier = _notifier(host=None)
|
||||
with patch("app.notifications.emailer.smtplib.SMTP") as smtp_cls:
|
||||
notifier.send(to_address="user@example.com", subject="s", body="b")
|
||||
smtp_cls.assert_not_called()
|
||||
|
||||
|
||||
def test_send_uses_starttls_and_login_when_configured() -> None:
|
||||
notifier = _notifier()
|
||||
smtp_instance = MagicMock()
|
||||
smtp_instance.__enter__.return_value = smtp_instance
|
||||
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance) as smtp_cls:
|
||||
notifier.send(to_address="rider@example.com", subject="Action required", body="Check the dashboard")
|
||||
|
||||
smtp_cls.assert_called_once_with("smtp.example.com", 587, timeout=10)
|
||||
smtp_instance.starttls.assert_called_once()
|
||||
smtp_instance.login.assert_called_once_with("user@example.com", "secret")
|
||||
assert smtp_instance.send_message.call_count == 1
|
||||
sent_message = smtp_instance.send_message.call_args[0][0]
|
||||
assert sent_message["To"] == "rider@example.com"
|
||||
assert sent_message["From"] == "sync@example.com"
|
||||
assert sent_message["Subject"] == "Action required"
|
||||
|
||||
|
||||
def test_send_skips_login_without_credentials() -> None:
|
||||
notifier = _notifier(username=None, password=None)
|
||||
smtp_instance = MagicMock()
|
||||
smtp_instance.__enter__.return_value = smtp_instance
|
||||
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
|
||||
notifier.send(to_address="rider@example.com", subject="s", body="b")
|
||||
|
||||
smtp_instance.login.assert_not_called()
|
||||
|
||||
|
||||
def test_send_skips_starttls_when_disabled() -> None:
|
||||
notifier = _notifier(use_tls=False)
|
||||
smtp_instance = MagicMock()
|
||||
smtp_instance.__enter__.return_value = smtp_instance
|
||||
with patch("app.notifications.emailer.smtplib.SMTP", return_value=smtp_instance):
|
||||
notifier.send(to_address="rider@example.com", subject="s", body="b")
|
||||
|
||||
smtp_instance.starttls.assert_not_called()
|
||||
0
tests/sync/__init__.py
Normal file
0
tests/sync/__init__.py
Normal file
200
tests/sync/conftest.py
Normal file
200
tests/sync/conftest.py
Normal file
@@ -0,0 +1,200 @@
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.models import Activity, ActivityStatus, Base, SyncUser
|
||||
from app.db.repositories import ActivityRepository, UserRepository
|
||||
from app.mywhoosh.models import MyWhooshActivity
|
||||
from app.security.credentials import CredentialCipher
|
||||
from app.sync.manager import SyncManager
|
||||
from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient
|
||||
|
||||
|
||||
class FakeFitConverter:
|
||||
"""Fit converter stub that mimics convert_fit_device's side effect of
|
||||
writing bytes to output_path, without doing any real FIT parsing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def __call__(self, source_path: Path, output_path: Path):
|
||||
self.calls += 1
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake-fit-bytes")
|
||||
return None
|
||||
|
||||
|
||||
class StubSettings:
|
||||
"""Minimal stand-in for app.config.Settings exposing only the two
|
||||
properties SyncManager needs; avoids constructing a full Settings with
|
||||
its several required env-backed fields."""
|
||||
|
||||
def __init__(self, tmp_path: Path) -> None:
|
||||
self.tokens_dir = tmp_path / "tokens"
|
||||
self.activities_dir = tmp_path / "activities"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory():
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cipher() -> CredentialCipher:
|
||||
return CredentialCipher(Fernet.generate_key().decode("ascii"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path: Path) -> StubSettings:
|
||||
return StubSettings(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load_only_activity(session_factory) -> Callable[[int], Activity]:
|
||||
def _load(user_id: int) -> Activity:
|
||||
with session_factory() as session:
|
||||
activities = list(session.scalars(select(Activity).where(Activity.user_id == user_id)))
|
||||
assert len(activities) == 1, f"expected exactly one activity for user {user_id}, found {len(activities)}"
|
||||
return activities[0]
|
||||
|
||||
return _load
|
||||
|
||||
|
||||
def _create_user(session: Session, cipher: CredentialCipher) -> SyncUser:
|
||||
return UserRepository(session).create(
|
||||
name="Test User",
|
||||
enabled=True,
|
||||
mywhoosh_email_enc=cipher.encrypt("mywhoosh@example.com"),
|
||||
mywhoosh_password_enc=cipher.encrypt("mywhoosh-pass"),
|
||||
garmin_email_enc=cipher.encrypt("garmin@example.com"),
|
||||
garmin_password_enc=cipher.encrypt("garmin-pass"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_user(session_factory, cipher: CredentialCipher) -> SyncUser:
|
||||
with session_factory() as session:
|
||||
return _create_user(session, cipher)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager_factory(session_factory, cipher: CredentialCipher, settings: StubSettings):
|
||||
"""Build a SyncManager plus its injected fakes, wired so the fake
|
||||
MyWhoosh client's single remote activity matches the given (already
|
||||
seeded) Activity's mywhoosh_activity_id -- so get_or_create_discovered
|
||||
resolves to the existing row instead of creating a new one."""
|
||||
|
||||
def _factory(activity: Activity):
|
||||
remote = MyWhooshActivity(
|
||||
id=activity.mywhoosh_activity_id,
|
||||
title=activity.activity_name,
|
||||
activity_file_id=f"file-{activity.mywhoosh_activity_id}",
|
||||
started_at=activity.activity_timestamp,
|
||||
)
|
||||
mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes")
|
||||
converter = FakeFitConverter()
|
||||
garmin = FakeGarminUploader()
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: mywhoosh,
|
||||
garmin_factory=lambda email, password, tokenstore: garmin,
|
||||
fit_converter=converter,
|
||||
)
|
||||
return manager, mywhoosh, converter, garmin
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(session_factory, cipher: CredentialCipher, settings: StubSettings, seeded_user: SyncUser):
|
||||
"""A manager wired for the happy-path new-activity scenario: one remote
|
||||
MyWhoosh activity that seeded_user has never seen before."""
|
||||
remote = MyWhooshActivity(
|
||||
id="mw-1",
|
||||
title="Morning Ride",
|
||||
activity_file_id="file-mw-1",
|
||||
started_at=None,
|
||||
)
|
||||
mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes")
|
||||
converter = FakeFitConverter()
|
||||
garmin = FakeGarminUploader()
|
||||
sync_manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: mywhoosh,
|
||||
garmin_factory=lambda email, password, tokenstore: garmin,
|
||||
fit_converter=converter,
|
||||
)
|
||||
# Exposed for tests that want to introspect fakes without a
|
||||
# manager_factory-style scenario.
|
||||
sync_manager.fake_mywhoosh = mywhoosh
|
||||
sync_manager.fake_converter = converter
|
||||
sync_manager.fake_garmin = garmin
|
||||
return sync_manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_activity_factory(session_factory, cipher: CredentialCipher):
|
||||
def _factory(
|
||||
*,
|
||||
status: ActivityStatus,
|
||||
last_completed_stage: ActivityStatus,
|
||||
retryable: bool,
|
||||
mywhoosh_activity_id: str = "mw-1",
|
||||
) -> Activity:
|
||||
with session_factory() as session:
|
||||
user = _create_user(session, cipher)
|
||||
activity_repo = ActivityRepository(session)
|
||||
activity, _created = activity_repo.get_or_create_discovered(
|
||||
user_id=user.id,
|
||||
mywhoosh_activity_id=mywhoosh_activity_id,
|
||||
activity_name="Test Activity",
|
||||
activity_timestamp=None,
|
||||
)
|
||||
activity.status = status
|
||||
activity.last_completed_stage = last_completed_stage
|
||||
activity.retryable = retryable
|
||||
if status in (
|
||||
ActivityStatus.DOWNLOADED,
|
||||
ActivityStatus.CONVERTED,
|
||||
ActivityStatus.IMPORTED,
|
||||
ActivityStatus.DUPLICATE,
|
||||
) or last_completed_stage in (
|
||||
ActivityStatus.DOWNLOADED,
|
||||
ActivityStatus.CONVERTED,
|
||||
ActivityStatus.IMPORTED,
|
||||
ActivityStatus.DUPLICATE,
|
||||
):
|
||||
activity.source_fit_path = "seed-source.fit"
|
||||
if status in (
|
||||
ActivityStatus.CONVERTED,
|
||||
ActivityStatus.IMPORTED,
|
||||
ActivityStatus.DUPLICATE,
|
||||
) or last_completed_stage in (
|
||||
ActivityStatus.CONVERTED,
|
||||
ActivityStatus.IMPORTED,
|
||||
ActivityStatus.DUPLICATE,
|
||||
):
|
||||
activity.converted_fit_path = "seed-converted.fit"
|
||||
session.commit()
|
||||
return activity
|
||||
|
||||
return _factory
|
||||
38
tests/sync/fakes.py
Normal file
38
tests/sync/fakes.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from app.garmin.uploader import UploadResult
|
||||
|
||||
|
||||
class FakeMyWhooshClient:
|
||||
def __init__(self, activities, fit_bytes: bytes) -> None:
|
||||
self.activities = activities
|
||||
self.fit_bytes = fit_bytes
|
||||
self.list_calls = 0
|
||||
self.download_calls = 0
|
||||
|
||||
async def list_activities(self, email: str, password: str):
|
||||
self.list_calls += 1
|
||||
return list(self.activities)
|
||||
|
||||
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
|
||||
self.download_calls += 1
|
||||
return self.fit_bytes
|
||||
|
||||
|
||||
class FakeGarminUploader:
|
||||
def __init__(self, result: UploadResult | None = None, error: Exception | None = None) -> None:
|
||||
self.result = result or UploadResult("imported", False, "g-1", {"activityId": "g-1"})
|
||||
self.error = error
|
||||
self.calls = 0
|
||||
|
||||
def import_fit(self, fit_path, mfa_code=None):
|
||||
self.calls += 1
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.result
|
||||
|
||||
|
||||
class FakeNotifier:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[dict] = []
|
||||
|
||||
def send(self, *, to_address: str, subject: str, body: str) -> None:
|
||||
self.sent.append({"to_address": to_address, "subject": subject, "body": body})
|
||||
163
tests/sync/test_concurrency.py
Normal file
163
tests/sync/test_concurrency.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.repositories import UserRepository
|
||||
from app.mywhoosh.client import MyWhooshAuthError
|
||||
from app.mywhoosh.models import MyWhooshActivity
|
||||
from app.sync.manager import SyncAlreadyRunning, SyncManager
|
||||
from tests.sync.conftest import FakeFitConverter, _create_user
|
||||
from tests.sync.fakes import FakeGarminUploader
|
||||
|
||||
|
||||
class BlockingMyWhooshClient:
|
||||
"""Fake MyWhoosh client whose list_activities() blocks on test-controlled
|
||||
events, so a test can deterministically observe "sync has started but not
|
||||
finished" without any production-only test hooks."""
|
||||
|
||||
def __init__(self, first_started: asyncio.Event, release: asyncio.Event) -> None:
|
||||
self.first_started = first_started
|
||||
self.release = release
|
||||
self.list_calls = 0
|
||||
|
||||
async def list_activities(self, email: str, password: str):
|
||||
self.list_calls += 1
|
||||
self.first_started.set()
|
||||
await self.release.wait()
|
||||
return []
|
||||
|
||||
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
|
||||
raise AssertionError("download_fit should not be reached in this test")
|
||||
|
||||
|
||||
class ConditionalFailureMyWhooshClient:
|
||||
"""Fake MyWhoosh client that raises MyWhooshAuthError only for a specific
|
||||
account email, letting one user's sync fail while others succeed."""
|
||||
|
||||
def __init__(self, activities, fit_bytes: bytes, failing_email: str) -> None:
|
||||
self.activities = activities
|
||||
self.fit_bytes = fit_bytes
|
||||
self.failing_email = failing_email
|
||||
self.list_calls = 0
|
||||
self.download_calls = 0
|
||||
|
||||
async def list_activities(self, email: str, password: str):
|
||||
self.list_calls += 1
|
||||
if email == self.failing_email:
|
||||
raise MyWhooshAuthError("simulated auth failure")
|
||||
return list(self.activities)
|
||||
|
||||
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
|
||||
self.download_calls += 1
|
||||
return self.fit_bytes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_a(session_factory, cipher):
|
||||
with session_factory() as session:
|
||||
return _create_user(session, cipher)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_b(session_factory, cipher):
|
||||
with session_factory() as session:
|
||||
return _create_user(session, cipher)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_user_cannot_run_twice(session_factory, cipher, settings, seeded_user) -> None:
|
||||
first_started = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
mywhoosh = BlockingMyWhooshClient(first_started, release_first)
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: mywhoosh,
|
||||
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
|
||||
fit_converter=FakeFitConverter(),
|
||||
)
|
||||
|
||||
first = asyncio.create_task(manager.sync_user(seeded_user.id))
|
||||
await first_started.wait()
|
||||
|
||||
with pytest.raises(SyncAlreadyRunning):
|
||||
await manager.sync_user(seeded_user.id)
|
||||
|
||||
release_first.set()
|
||||
outcome = await first
|
||||
|
||||
assert outcome.user_id == seeded_user.id
|
||||
assert mywhoosh.list_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_users_can_run_concurrently(manager, user_a, user_b) -> None:
|
||||
results = await asyncio.gather(manager.sync_user(user_a.id), manager.sync_user(user_b.id))
|
||||
assert {result.user_id for result in results} == {user_a.id, user_b.id}
|
||||
assert all(result.status == "success" for result in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_all_enabled_isolates_failures(session_factory, cipher, settings) -> None:
|
||||
with session_factory() as session:
|
||||
failing_user = UserRepository(session).create(
|
||||
name="Failing User",
|
||||
enabled=True,
|
||||
mywhoosh_email_enc=cipher.encrypt("failing-mw@example.com"),
|
||||
mywhoosh_password_enc=cipher.encrypt("failing-mw-pass"),
|
||||
garmin_email_enc=cipher.encrypt("failing-garmin@example.com"),
|
||||
garmin_password_enc=cipher.encrypt("failing-garmin-pass"),
|
||||
)
|
||||
healthy_user = UserRepository(session).create(
|
||||
name="Healthy User",
|
||||
enabled=True,
|
||||
mywhoosh_email_enc=cipher.encrypt("healthy-mw@example.com"),
|
||||
mywhoosh_password_enc=cipher.encrypt("healthy-mw-pass"),
|
||||
garmin_email_enc=cipher.encrypt("healthy-garmin@example.com"),
|
||||
garmin_password_enc=cipher.encrypt("healthy-garmin-pass"),
|
||||
)
|
||||
|
||||
remote = MyWhooshActivity(
|
||||
id="mw-shared",
|
||||
title="Shared Ride",
|
||||
activity_file_id="file-mw-shared",
|
||||
started_at=None,
|
||||
)
|
||||
mywhoosh = ConditionalFailureMyWhooshClient(
|
||||
activities=[remote],
|
||||
fit_bytes=b"source-bytes",
|
||||
failing_email="failing-mw@example.com",
|
||||
)
|
||||
converter = FakeFitConverter()
|
||||
garmin = FakeGarminUploader()
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: mywhoosh,
|
||||
garmin_factory=lambda email, password, tokenstore: garmin,
|
||||
fit_converter=converter,
|
||||
)
|
||||
|
||||
results = await manager.sync_all_enabled()
|
||||
|
||||
assert len(results) == 2
|
||||
for result in results:
|
||||
assert not isinstance(result, Exception)
|
||||
|
||||
by_user = {result.user_id: result for result in results}
|
||||
failing_outcome = by_user[failing_user.id]
|
||||
healthy_outcome = by_user[healthy_user.id]
|
||||
|
||||
# The failing user's auth error is classified by _sync_user_locked's own
|
||||
# exception handling and returned as a non-success SyncOutcome rather than
|
||||
# raised -- so asyncio.gather never sees an exception for this failure
|
||||
# mode. It must not affect the healthy user's independent outcome.
|
||||
assert failing_outcome.status != "success"
|
||||
assert failing_outcome.message is not None
|
||||
assert failing_outcome.discovered == 0
|
||||
|
||||
assert healthy_outcome.status == "success"
|
||||
assert healthy_outcome.imported == 1
|
||||
assert healthy_outcome.failed == 0
|
||||
412
tests/sync/test_manager.py
Normal file
412
tests/sync/test_manager.py
Normal file
@@ -0,0 +1,412 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import ActivityStatus, HealthState, SyncRun, SyncRunStatus, SyncUser
|
||||
from app.db.repositories import SystemLogRepository, UserRepository
|
||||
from app.garmin.uploader import UploadResult
|
||||
from app.mywhoosh.client import MyWhooshDeviceConflictError
|
||||
from app.mywhoosh.models import MyWhooshActivity
|
||||
from app.notifications.emailer import EmailNotifier
|
||||
from app.sync.manager import SyncManager
|
||||
from tests.sync.conftest import FakeFitConverter, _create_user
|
||||
from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient, FakeNotifier
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_activity_downloads_converts_and_imports(manager, seeded_user: SyncUser, load_only_activity) -> None:
|
||||
outcome = await manager.sync_user(seeded_user.id)
|
||||
|
||||
assert outcome.discovered == 1
|
||||
assert outcome.imported == 1
|
||||
assert outcome.failed == 0
|
||||
|
||||
activity = load_only_activity(seeded_user.id)
|
||||
assert activity.status == ActivityStatus.IMPORTED
|
||||
assert Path(activity.source_fit_path).exists()
|
||||
assert Path(activity.converted_fit_path).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("status", "last_stage", "expected_downloads", "expected_conversions", "expected_imports"),
|
||||
[
|
||||
(ActivityStatus.DOWNLOADED, ActivityStatus.DOWNLOADED, 0, 1, 1),
|
||||
(ActivityStatus.CONVERTED, ActivityStatus.CONVERTED, 0, 0, 1),
|
||||
(ActivityStatus.IMPORTED, ActivityStatus.IMPORTED, 0, 0, 0),
|
||||
(ActivityStatus.FAILED, ActivityStatus.CONVERTED, 0, 0, 1),
|
||||
],
|
||||
)
|
||||
async def test_resume_from_durable_stage(
|
||||
manager_factory,
|
||||
seeded_activity_factory,
|
||||
status,
|
||||
last_stage,
|
||||
expected_downloads,
|
||||
expected_conversions,
|
||||
expected_imports,
|
||||
) -> None:
|
||||
activity = seeded_activity_factory(status=status, last_completed_stage=last_stage, retryable=True)
|
||||
manager, mywhoosh, converter, garmin = manager_factory(activity)
|
||||
await manager.sync_user(activity.user_id)
|
||||
assert mywhoosh.download_calls == expected_downloads
|
||||
assert converter.calls == expected_conversions
|
||||
assert garmin.calls == expected_imports
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_retryable_failed_activity_is_never_retried(
|
||||
manager_factory,
|
||||
seeded_activity_factory,
|
||||
load_only_activity,
|
||||
) -> None:
|
||||
activity = seeded_activity_factory(
|
||||
status=ActivityStatus.FAILED,
|
||||
last_completed_stage=ActivityStatus.CONVERTED,
|
||||
retryable=False,
|
||||
)
|
||||
manager, mywhoosh, converter, garmin = manager_factory(activity)
|
||||
|
||||
outcome = await manager.sync_user(activity.user_id)
|
||||
|
||||
assert mywhoosh.download_calls == 0
|
||||
assert converter.calls == 0
|
||||
assert garmin.calls == 0
|
||||
assert outcome.imported == 0
|
||||
assert outcome.skipped == 0
|
||||
assert outcome.failed == 0
|
||||
|
||||
reloaded = load_only_activity(activity.user_id)
|
||||
assert reloaded.status == ActivityStatus.FAILED
|
||||
assert reloaded.retryable is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_run_repository_wiring_records_run(manager, seeded_user: SyncUser, session_factory) -> None:
|
||||
await manager.sync_user(seeded_user.id)
|
||||
|
||||
with session_factory() as session:
|
||||
runs = list(session.scalars(select(SyncRun).where(SyncRun.user_id == seeded_user.id)))
|
||||
assert len(runs) == 1
|
||||
run = runs[0]
|
||||
assert run.status == SyncRunStatus.SUCCESS
|
||||
assert run.discovered_count == 1
|
||||
assert run.imported_count == 1
|
||||
assert run.finished_at is not None
|
||||
|
||||
|
||||
class RecordingGarminUploader:
|
||||
"""Fake Garmin uploader that records the mfa_code it was actually called
|
||||
with, so a test can prove the value genuinely threads through
|
||||
SyncManager._sync_user_locked's asyncio.to_thread(garmin.import_fit, ...)
|
||||
call rather than just through the web route's own fake manager."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received_mfa_codes: list[str | None] = []
|
||||
|
||||
def import_fit(self, fit_path, mfa_code=None):
|
||||
self.received_mfa_codes.append(mfa_code)
|
||||
return UploadResult("imported", False, "g-1", {"activityId": "g-1"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garmin_action_required_not_cleared_by_run_with_no_garmin_work(
|
||||
session_factory, cipher, settings
|
||||
) -> None:
|
||||
"""Regression test for the health-state-reset bug: a user stuck at
|
||||
garmin_auth_required must NOT be silently cleared back to healthy just
|
||||
because a run's MyWhoosh listing succeeded trivially (zero remote
|
||||
activities means zero Garmin work was attempted -- no evidence Garmin
|
||||
was actually fixed)."""
|
||||
with session_factory() as session:
|
||||
user = _create_user(session, cipher)
|
||||
user.health_state = HealthState.ACTION_REQUIRED
|
||||
user.garmin_state = "auth_required"
|
||||
user.action_reason = "garmin_auth_required"
|
||||
session.commit()
|
||||
user_id = user.id
|
||||
|
||||
mywhoosh = FakeMyWhooshClient(activities=[], fit_bytes=b"unused")
|
||||
converter = FakeFitConverter()
|
||||
garmin = RecordingGarminUploader()
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: mywhoosh,
|
||||
garmin_factory=lambda email, password, tokenstore: garmin,
|
||||
fit_converter=converter,
|
||||
)
|
||||
|
||||
outcome = await manager.sync_user(user_id)
|
||||
assert outcome.status == "success"
|
||||
assert outcome.discovered == 0
|
||||
|
||||
with session_factory() as session:
|
||||
reloaded = UserRepository(session).get(user_id)
|
||||
assert reloaded.action_reason == "garmin_auth_required"
|
||||
assert reloaded.health_state == HealthState.ACTION_REQUIRED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_garmin_action_required_cleared_after_successful_import(
|
||||
session_factory, cipher, settings
|
||||
) -> None:
|
||||
"""Companion to the regression test above: the same starting
|
||||
action_required/garmin_auth_required state IS cleared once this run
|
||||
actually succeeds at a real Garmin import -- proving the fix doesn't just
|
||||
always refuse to clear."""
|
||||
with session_factory() as session:
|
||||
user = _create_user(session, cipher)
|
||||
user.health_state = HealthState.ACTION_REQUIRED
|
||||
user.garmin_state = "auth_required"
|
||||
user.action_reason = "garmin_auth_required"
|
||||
session.commit()
|
||||
user_id = user.id
|
||||
|
||||
remote = MyWhooshActivity(
|
||||
id="mw-recovery-1", title="Recovery Ride", activity_file_id="file-mw-recovery-1", started_at=None
|
||||
)
|
||||
mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes")
|
||||
converter = FakeFitConverter()
|
||||
garmin = RecordingGarminUploader()
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: mywhoosh,
|
||||
garmin_factory=lambda email, password, tokenstore: garmin,
|
||||
fit_converter=converter,
|
||||
)
|
||||
|
||||
outcome = await manager.sync_user(user_id)
|
||||
assert outcome.status == "success"
|
||||
assert outcome.imported == 1
|
||||
|
||||
with session_factory() as session:
|
||||
reloaded = UserRepository(session).get(user_id)
|
||||
assert reloaded.action_reason is None
|
||||
assert reloaded.health_state == HealthState.HEALTHY
|
||||
|
||||
|
||||
class DeviceConflictMyWhooshClient:
|
||||
"""Fake MyWhoosh client that always raises MyWhooshDeviceConflictError
|
||||
from list_activities, simulating MyWhoosh's "already logged in from
|
||||
another device" response."""
|
||||
|
||||
async def list_activities(self, email: str, password: str):
|
||||
raise MyWhooshDeviceConflictError("You are already logged in from another device.")
|
||||
|
||||
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
|
||||
raise AssertionError("download_fit should not be reached in this test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_conflict_sets_distinct_action_reason(seeded_user: SyncUser, session_factory, cipher, settings) -> None:
|
||||
"""A MyWhoosh device-conflict response must be distinguishable in the UI
|
||||
from a generic auth failure, so users get an actionable hint instead of
|
||||
being told to re-check their password."""
|
||||
mywhoosh = DeviceConflictMyWhooshClient()
|
||||
converter = FakeFitConverter()
|
||||
garmin = FakeGarminUploader()
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: mywhoosh,
|
||||
garmin_factory=lambda email, password, tokenstore: garmin,
|
||||
fit_converter=converter,
|
||||
)
|
||||
|
||||
outcome = await manager.sync_user(seeded_user.id)
|
||||
assert outcome.status == "failed"
|
||||
assert "another device" in outcome.message
|
||||
|
||||
with session_factory() as session:
|
||||
reloaded = UserRepository(session).get(seeded_user.id)
|
||||
assert reloaded.action_reason == "mywhoosh_device_conflict"
|
||||
assert reloaded.mywhoosh_state == "device_conflict"
|
||||
assert reloaded.health_state == HealthState.ACTION_REQUIRED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notifies_on_new_action_required_when_opted_in(session_factory, cipher, settings) -> None:
|
||||
with session_factory() as session:
|
||||
user = _create_user(session, cipher)
|
||||
user.notify_email_enabled = True
|
||||
user.notification_email = "alerts@example.com"
|
||||
session.commit()
|
||||
user_id = user.id
|
||||
|
||||
notifier = FakeNotifier()
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
|
||||
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
|
||||
fit_converter=FakeFitConverter(),
|
||||
notifier=notifier,
|
||||
)
|
||||
|
||||
await manager.sync_user(user_id)
|
||||
|
||||
assert len(notifier.sent) == 1
|
||||
assert notifier.sent[0]["to_address"] == "alerts@example.com"
|
||||
assert "another device" in notifier.sent[0]["body"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_notify_when_not_opted_in(session_factory, cipher, settings) -> None:
|
||||
user_id = None
|
||||
with session_factory() as session:
|
||||
user = _create_user(session, cipher)
|
||||
user_id = user.id
|
||||
|
||||
notifier = FakeNotifier()
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
|
||||
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
|
||||
fit_converter=FakeFitConverter(),
|
||||
notifier=notifier,
|
||||
)
|
||||
|
||||
await manager.sync_user(user_id)
|
||||
|
||||
assert notifier.sent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_renotify_for_unresolved_unchanged_reason(session_factory, cipher, settings) -> None:
|
||||
with session_factory() as session:
|
||||
user = _create_user(session, cipher)
|
||||
user.notify_email_enabled = True
|
||||
user.notification_email = "alerts@example.com"
|
||||
session.commit()
|
||||
user_id = user.id
|
||||
|
||||
notifier = FakeNotifier()
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
|
||||
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
|
||||
fit_converter=FakeFitConverter(),
|
||||
notifier=notifier,
|
||||
)
|
||||
|
||||
await manager.sync_user(user_id)
|
||||
await manager.sync_user(user_id)
|
||||
|
||||
assert len(notifier.sent) == 1
|
||||
|
||||
|
||||
class FailingNotifier:
|
||||
def send(self, *, to_address: str, subject: str, body: str) -> None:
|
||||
raise RuntimeError("SMTP connection refused")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_send_failure_is_recorded_in_system_log_and_does_not_break_sync(
|
||||
session_factory, cipher, settings
|
||||
) -> None:
|
||||
with session_factory() as session:
|
||||
user = _create_user(session, cipher)
|
||||
user.notify_email_enabled = True
|
||||
user.notification_email = "alerts@example.com"
|
||||
session.commit()
|
||||
user_id = user.id
|
||||
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
|
||||
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
|
||||
fit_converter=FakeFitConverter(),
|
||||
notifier=FailingNotifier(),
|
||||
)
|
||||
|
||||
outcome = await manager.sync_user(user_id)
|
||||
assert outcome.status == "failed"
|
||||
|
||||
with session_factory() as session:
|
||||
entries = SystemLogRepository(session).list_recent()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].source == "email_notification"
|
||||
assert entries[0].user_id == user_id
|
||||
assert "SMTP connection refused" in entries[0].message
|
||||
assert "alerts@example.com" in entries[0].message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unconfigured_smtp_is_recorded_in_system_log_not_silently_dropped(
|
||||
session_factory, cipher, settings
|
||||
) -> None:
|
||||
"""Regression test: an EmailNotifier with no SMTP_HOST configured skips
|
||||
sending without raising, which used to look identical to "notifications
|
||||
disabled" -- an opted-in user got neither an email nor any trace of why,
|
||||
with nothing to debug from. This must now leave a system log entry."""
|
||||
with session_factory() as session:
|
||||
user = _create_user(session, cipher)
|
||||
user.notify_email_enabled = True
|
||||
user.notification_email = "alerts@example.com"
|
||||
session.commit()
|
||||
user_id = user.id
|
||||
|
||||
unconfigured_notifier = EmailNotifier(
|
||||
host=None, port=587, username=None, password=None, from_address=None, use_tls=True
|
||||
)
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: DeviceConflictMyWhooshClient(),
|
||||
garmin_factory=lambda email, password, tokenstore: FakeGarminUploader(),
|
||||
fit_converter=FakeFitConverter(),
|
||||
notifier=unconfigured_notifier,
|
||||
)
|
||||
|
||||
await manager.sync_user(user_id)
|
||||
|
||||
with session_factory() as session:
|
||||
entries = SystemLogRepository(session).list_recent()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].source == "email_notification"
|
||||
assert "SMTP is not configured" in entries[0].message
|
||||
assert "alerts@example.com" in entries[0].message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager(
|
||||
session_factory, cipher, settings, seeded_user: SyncUser
|
||||
) -> None:
|
||||
"""Proves mfa_code genuinely threads through _sync_user_locked's
|
||||
asyncio.to_thread(garmin.import_fit, converted_path, mfa_code) call in the
|
||||
real (non-web-route) code path -- tests/web/test_mfa.py already covers the
|
||||
web route's own fake manager, but not the real SyncManager/GarminUploader
|
||||
interface."""
|
||||
remote = MyWhooshActivity(
|
||||
id="mw-mfa-1", title="MFA Ride", activity_file_id="file-mw-mfa-1", started_at=None
|
||||
)
|
||||
mywhoosh = FakeMyWhooshClient(activities=[remote], fit_bytes=b"source-bytes")
|
||||
converter = FakeFitConverter()
|
||||
garmin = RecordingGarminUploader()
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=lambda token_store: mywhoosh,
|
||||
garmin_factory=lambda email, password, tokenstore: garmin,
|
||||
fit_converter=converter,
|
||||
)
|
||||
|
||||
outcome = await manager.sync_user(seeded_user.id, mfa_code="123456")
|
||||
|
||||
assert outcome.status == "success"
|
||||
assert garmin.received_mfa_codes == ["123456"]
|
||||
136
tests/sync/test_scheduler.py
Normal file
136
tests/sync/test_scheduler.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db.repositories import SchedulerSettingsRepository
|
||||
from app.db.session import create_session_factory
|
||||
from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler, is_daytime
|
||||
|
||||
|
||||
class FakeSyncManager:
|
||||
def __init__(self, results=None) -> None:
|
||||
self.results = results if results is not None else []
|
||||
self.calls = 0
|
||||
|
||||
async def sync_all_enabled(self):
|
||||
self.calls += 1
|
||||
if self.results and isinstance(self.results[0], Exception):
|
||||
raise self.results[0]
|
||||
return list(self.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_calls_sync_all_and_survives_failure() -> None:
|
||||
fake = FakeSyncManager(results=[RuntimeError("one user failed")])
|
||||
scheduler = SyncScheduler(fake, interval_seconds=0.01)
|
||||
await scheduler.start()
|
||||
await asyncio.sleep(0.035)
|
||||
await scheduler.stop()
|
||||
assert fake.calls >= 2
|
||||
assert scheduler.last_tick is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_stop_cancels_the_loop() -> None:
|
||||
fake = FakeSyncManager()
|
||||
scheduler = SyncScheduler(fake, interval_seconds=0.01)
|
||||
await scheduler.start()
|
||||
await asyncio.sleep(0.035)
|
||||
await scheduler.stop()
|
||||
|
||||
assert scheduler._task is None
|
||||
calls_after_stop = fake.calls
|
||||
await asyncio.sleep(0.05)
|
||||
assert fake.calls == calls_after_stop
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hour", "day_start", "night_start", "expected"),
|
||||
[
|
||||
(6, 6, 22, True),
|
||||
(21, 6, 22, True),
|
||||
(5, 6, 22, False),
|
||||
(22, 6, 22, False),
|
||||
(0, 6, 22, False),
|
||||
# wraps past midnight: day period is [20, 6)
|
||||
(23, 20, 6, True),
|
||||
(2, 20, 6, True),
|
||||
(10, 20, 6, False),
|
||||
(20, 20, 6, True),
|
||||
(6, 20, 6, False),
|
||||
# degenerate: identical start hours means always day
|
||||
(13, 9, 9, True),
|
||||
],
|
||||
)
|
||||
def test_is_daytime(hour, day_start, night_start, expected) -> None:
|
||||
assert is_daytime(hour, day_start, night_start) is expected
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session_factory():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = create_session_factory(engine)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interval_provider_uses_day_interval_during_the_day(db_session_factory) -> None:
|
||||
provider = DayNightIntervalProvider(
|
||||
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
|
||||
)
|
||||
assert provider() == 5 * 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interval_provider_uses_night_interval_at_night(db_session_factory) -> None:
|
||||
with db_session_factory() as session:
|
||||
repo = SchedulerSettingsRepository(session)
|
||||
row = repo.get_or_create(default_minutes=5)
|
||||
repo.update(row, night_interval_minutes=30)
|
||||
|
||||
provider = DayNightIntervalProvider(
|
||||
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 23, 0)
|
||||
)
|
||||
assert provider() == 30 * 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_uses_interval_provider_for_next_tick(db_session_factory) -> None:
|
||||
with db_session_factory() as session:
|
||||
repo = SchedulerSettingsRepository(session)
|
||||
row = repo.get_or_create(default_minutes=5)
|
||||
repo.update(row, day_interval_minutes=1, night_interval_minutes=1)
|
||||
|
||||
provider = DayNightIntervalProvider(
|
||||
db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0)
|
||||
)
|
||||
fake = FakeSyncManager()
|
||||
scheduler = SyncScheduler(fake, interval_seconds=999, interval_provider=provider)
|
||||
|
||||
await scheduler.run_once()
|
||||
|
||||
expected_seconds = 60 # day_interval_minutes=1 -> 60s, not the unrelated interval_seconds=999 fallback
|
||||
delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
|
||||
assert abs(delta - expected_seconds) < 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_falls_back_when_interval_provider_raises() -> None:
|
||||
def broken_provider():
|
||||
raise RuntimeError("db unavailable")
|
||||
|
||||
fake = FakeSyncManager()
|
||||
scheduler = SyncScheduler(fake, interval_seconds=42, interval_provider=broken_provider)
|
||||
|
||||
await scheduler.run_once()
|
||||
|
||||
delta = (scheduler.next_tick - scheduler.last_tick).total_seconds()
|
||||
assert abs(delta - 42) < 1
|
||||
268
tests/test_acceptance.py
Normal file
268
tests/test_acceptance.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Application-level, multi-user acceptance tests for the full MyWhoosh -> Garmin
|
||||
sync pipeline (Task 7 of the sync-scheduler-web plan).
|
||||
|
||||
These tests build a real `SyncManager` wired to a real SQLite database and a
|
||||
real `CredentialCipher`, but with fake MyWhoosh/Garmin factories, so they
|
||||
exercise the whole state machine end-to-end for two independent users without
|
||||
touching any real external service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db.models import Activity, ActivityStatus, HealthState, SyncUser
|
||||
from app.db.repositories import UserRepository
|
||||
from app.db.session import create_db_engine, create_session_factory, initialize_schema
|
||||
from app.garmin.uploader import GarminUploadBlocked, UploadResult
|
||||
from app.mywhoosh.models import MyWhooshActivity
|
||||
from app.security.credentials import CredentialCipher
|
||||
from app.sync.manager import SyncManager
|
||||
|
||||
|
||||
class StubSettings:
|
||||
"""Minimal stand-in for app.config.Settings exposing only the two
|
||||
properties SyncManager needs."""
|
||||
|
||||
def __init__(self, tmp_path: Path) -> None:
|
||||
self.tokens_dir = tmp_path / "tokens"
|
||||
self.activities_dir = tmp_path / "activities"
|
||||
|
||||
|
||||
class FakeMyWhooshClient:
|
||||
def __init__(self, activities, fit_bytes: bytes) -> None:
|
||||
self.activities = activities
|
||||
self.fit_bytes = fit_bytes
|
||||
self.list_calls = 0
|
||||
self.download_calls = 0
|
||||
|
||||
async def list_activities(self, email, password):
|
||||
self.list_calls += 1
|
||||
return list(self.activities)
|
||||
|
||||
async def download_fit(self, activity_file_id, email, password):
|
||||
self.download_calls += 1
|
||||
return self.fit_bytes
|
||||
|
||||
|
||||
class FakeGarminUploader:
|
||||
def __init__(self, result: UploadResult | None = None, error: Exception | None = None) -> None:
|
||||
self.result = result or UploadResult("imported", False, "g-1", {"activityId": "g-1"})
|
||||
self.error = error
|
||||
self.calls = 0
|
||||
|
||||
def import_fit(self, fit_path, mfa_code=None):
|
||||
self.calls += 1
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.result
|
||||
|
||||
|
||||
def fake_fit_converter(source_path: Path, output_path: Path) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake-converted-fit-bytes")
|
||||
|
||||
|
||||
def count_terminal_activities(session_factory: sessionmaker, user_id: int) -> int:
|
||||
with session_factory() as session:
|
||||
return session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Activity)
|
||||
.where(
|
||||
Activity.user_id == user_id,
|
||||
Activity.status.in_([ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE]),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _create_user(session, cipher: CredentialCipher, *, name: str) -> SyncUser:
|
||||
return UserRepository(session).create(
|
||||
name=name,
|
||||
enabled=True,
|
||||
mywhoosh_email_enc=cipher.encrypt(f"{name.lower()}-mywhoosh@example.com"),
|
||||
mywhoosh_password_enc=cipher.encrypt("mw-secret"),
|
||||
garmin_email_enc=cipher.encrypt(f"{name.lower()}-garmin@example.com"),
|
||||
garmin_password_enc=cipher.encrypt("garmin-secret"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(tmp_path: Path):
|
||||
db_path = tmp_path / "acceptance.db"
|
||||
engine = create_db_engine(f"sqlite:///{db_path}")
|
||||
initialize_schema(engine)
|
||||
factory = create_session_factory(engine)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cipher() -> CredentialCipher:
|
||||
return CredentialCipher(Fernet.generate_key().decode("ascii"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path: Path) -> StubSettings:
|
||||
return StubSettings(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_users(session_factory, cipher: CredentialCipher):
|
||||
with session_factory() as session:
|
||||
user_a = _create_user(session, cipher, name="Alice")
|
||||
user_b = _create_user(session, cipher, name="Bob")
|
||||
return user_a, user_b
|
||||
|
||||
|
||||
def _build_manager(
|
||||
*,
|
||||
session_factory,
|
||||
cipher: CredentialCipher,
|
||||
settings: StubSettings,
|
||||
user_a: SyncUser,
|
||||
user_b: SyncUser,
|
||||
fake_mw_a: FakeMyWhooshClient,
|
||||
fake_mw_b: FakeMyWhooshClient,
|
||||
fake_garmin_a: FakeGarminUploader,
|
||||
fake_garmin_b: FakeGarminUploader,
|
||||
):
|
||||
mywhoosh_fakes = {str(user_a.id): fake_mw_a, str(user_b.id): fake_mw_b}
|
||||
garmin_fakes = {str(user_a.id): fake_garmin_a, str(user_b.id): fake_garmin_b}
|
||||
garmin_factory_calls: list[tuple[str, str, Path]] = []
|
||||
|
||||
def mywhoosh_factory(token_store):
|
||||
user_key = token_store.path.parent.name
|
||||
return mywhoosh_fakes[user_key]
|
||||
|
||||
def garmin_factory(email, password, tokenstore):
|
||||
garmin_factory_calls.append((email, password, tokenstore))
|
||||
user_key = tokenstore.parent.name
|
||||
return garmin_fakes[user_key]
|
||||
|
||||
manager = SyncManager(
|
||||
session_factory=session_factory,
|
||||
credential_cipher=cipher,
|
||||
settings=settings,
|
||||
mywhoosh_factory=mywhoosh_factory,
|
||||
garmin_factory=garmin_factory,
|
||||
fit_converter=fake_fit_converter,
|
||||
)
|
||||
return manager, garmin_factory_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_user_happy_path(session_factory, cipher, settings, two_users):
|
||||
user_a, user_b = two_users
|
||||
|
||||
remote_a = MyWhooshActivity(
|
||||
id="remote-a-1", title="Alice Ride", activity_file_id="file-a-1", started_at=None
|
||||
)
|
||||
remote_b = MyWhooshActivity(
|
||||
id="remote-b-1", title="Bob Ride", activity_file_id="file-b-1", started_at=None
|
||||
)
|
||||
fake_mw_a = FakeMyWhooshClient(activities=[remote_a], fit_bytes=b"alice-source-bytes")
|
||||
fake_mw_b = FakeMyWhooshClient(activities=[remote_b], fit_bytes=b"bob-source-bytes")
|
||||
fake_garmin_a = FakeGarminUploader()
|
||||
fake_garmin_b = FakeGarminUploader()
|
||||
|
||||
manager, garmin_factory_calls = _build_manager(
|
||||
session_factory=session_factory,
|
||||
cipher=cipher,
|
||||
settings=settings,
|
||||
user_a=user_a,
|
||||
user_b=user_b,
|
||||
fake_mw_a=fake_mw_a,
|
||||
fake_mw_b=fake_mw_b,
|
||||
fake_garmin_a=fake_garmin_a,
|
||||
fake_garmin_b=fake_garmin_b,
|
||||
)
|
||||
|
||||
results = await manager.sync_all_enabled()
|
||||
|
||||
assert all(result.status == "success" for result in results)
|
||||
assert count_terminal_activities(session_factory, user_a.id) == 1
|
||||
assert count_terminal_activities(session_factory, user_b.id) == 1
|
||||
|
||||
with session_factory() as session:
|
||||
activity_a = session.scalar(select(Activity).where(Activity.user_id == user_a.id))
|
||||
activity_b = session.scalar(select(Activity).where(Activity.user_id == user_b.id))
|
||||
assert Path(activity_a.source_fit_path).exists()
|
||||
assert Path(activity_b.source_fit_path).exists()
|
||||
assert Path(activity_a.source_fit_path).parent != Path(activity_b.source_fit_path).parent
|
||||
|
||||
tokenstores = {str(call[2]) for call in garmin_factory_calls}
|
||||
assert len(tokenstores) == 2
|
||||
|
||||
# Idempotency (spec 26.8): re-running sync_all_enabled with no new remote
|
||||
# activities must not re-download/re-convert/re-import anything, and must
|
||||
# not create a second terminal Activity row for the same MyWhoosh activity.
|
||||
download_calls_a_before = fake_mw_a.download_calls
|
||||
download_calls_b_before = fake_mw_b.download_calls
|
||||
garmin_calls_a_before = fake_garmin_a.calls
|
||||
garmin_calls_b_before = fake_garmin_b.calls
|
||||
|
||||
second_results = await manager.sync_all_enabled()
|
||||
|
||||
assert all(result.status == "success" for result in second_results)
|
||||
assert fake_mw_a.download_calls == download_calls_a_before
|
||||
assert fake_mw_b.download_calls == download_calls_b_before
|
||||
assert fake_garmin_a.calls == garmin_calls_a_before
|
||||
assert fake_garmin_b.calls == garmin_calls_b_before
|
||||
assert count_terminal_activities(session_factory, user_a.id) == 1
|
||||
assert count_terminal_activities(session_factory, user_b.id) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_isolation_when_one_user_requires_mfa(session_factory, cipher, settings, two_users):
|
||||
user_a, user_b = two_users
|
||||
|
||||
remote_a = MyWhooshActivity(
|
||||
id="remote-a-1", title="Alice Ride", activity_file_id="file-a-1", started_at=None
|
||||
)
|
||||
remote_b = MyWhooshActivity(
|
||||
id="remote-b-1", title="Bob Ride", activity_file_id="file-b-1", started_at=None
|
||||
)
|
||||
fake_mw_a = FakeMyWhooshClient(activities=[remote_a], fit_bytes=b"alice-source-bytes")
|
||||
fake_mw_b = FakeMyWhooshClient(activities=[remote_b], fit_bytes=b"bob-source-bytes")
|
||||
fake_garmin_a = FakeGarminUploader()
|
||||
fake_garmin_b = FakeGarminUploader(error=GarminUploadBlocked("Garmin requested MFA"))
|
||||
|
||||
manager, garmin_factory_calls = _build_manager(
|
||||
session_factory=session_factory,
|
||||
cipher=cipher,
|
||||
settings=settings,
|
||||
user_a=user_a,
|
||||
user_b=user_b,
|
||||
fake_mw_a=fake_mw_a,
|
||||
fake_mw_b=fake_mw_b,
|
||||
fake_garmin_a=fake_garmin_a,
|
||||
fake_garmin_b=fake_garmin_b,
|
||||
)
|
||||
|
||||
results = await manager.sync_all_enabled()
|
||||
|
||||
results_by_user = {result.user_id: result for result in results}
|
||||
assert results_by_user[user_a.id].status == "success"
|
||||
assert results_by_user[user_b.id].status != "success"
|
||||
assert results_by_user[user_b.id].status == "failed"
|
||||
|
||||
assert count_terminal_activities(session_factory, user_a.id) == 1
|
||||
|
||||
with session_factory() as session:
|
||||
reloaded_b = UserRepository(session).get(user_b.id)
|
||||
assert reloaded_b.health_state == HealthState.ACTION_REQUIRED
|
||||
assert reloaded_b.action_reason == "garmin_mfa_required"
|
||||
|
||||
reloaded_a = UserRepository(session).get(user_a.id)
|
||||
assert reloaded_a.health_state == HealthState.HEALTHY
|
||||
assert reloaded_a.action_reason is None
|
||||
|
||||
activity_a = session.scalar(select(Activity).where(Activity.user_id == user_a.id))
|
||||
assert activity_a.status in (ActivityStatus.IMPORTED, ActivityStatus.DUPLICATE)
|
||||
30
tests/test_main_lifespan.py
Normal file
30
tests/test_main_lifespan.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from app.sync.manager import SyncManager
|
||||
|
||||
|
||||
def test_lifespan_wires_sync_manager_and_scheduler(tmp_path: Path) -> None:
|
||||
settings = Settings(
|
||||
ADMIN_PASSWORD="admin-secret",
|
||||
SECRET_KEY="0123456789abcdef0123456789abcdef",
|
||||
CREDENTIAL_ENCRYPTION_KEY=Fernet.generate_key().decode("ascii"),
|
||||
DATA_DIR=str(tmp_path),
|
||||
DATABASE_URL=f"sqlite:///{tmp_path / 'app.db'}",
|
||||
SYNC_INTERVAL_MINUTES=5,
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
# No users are seeded in this database, so sync_all_enabled() has nothing
|
||||
# to iterate over and the real MyWhoosh/Garmin factories are never invoked.
|
||||
with TestClient(app):
|
||||
assert app.state.sync_manager is not None
|
||||
assert isinstance(app.state.sync_manager, SyncManager)
|
||||
assert app.state.scheduler is not None
|
||||
assert app.state.scheduler.last_tick is not None
|
||||
|
||||
app.state.db_engine.dispose()
|
||||
335
tests/web/test_account_web.py
Normal file
335
tests/web/test_account_web.py
Normal file
@@ -0,0 +1,335 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db.repositories import UserRepository
|
||||
from app.security.credentials import CredentialCipher
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def admin_login(client: TestClient) -> None:
|
||||
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
|
||||
|
||||
|
||||
def create_user_via_admin(client: TestClient, **overrides) -> int:
|
||||
admin_login(client)
|
||||
page = client.get("/users/new")
|
||||
csrf = extract_csrf(page.text)
|
||||
payload = {
|
||||
"csrf_token": csrf,
|
||||
"name": "Max",
|
||||
"mywhoosh_email": "max@mywhoosh.example",
|
||||
"mywhoosh_password": "mw-secret",
|
||||
"garmin_email": "max@garmin.example",
|
||||
"garmin_password": "garmin-secret",
|
||||
"enabled": "on",
|
||||
}
|
||||
payload.update(overrides)
|
||||
response = client.post("/users", data=payload, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
user_id = int(response.headers["location"].rsplit("/", 1)[-1])
|
||||
client.cookies.clear()
|
||||
return user_id
|
||||
|
||||
|
||||
def account_login(client: TestClient, *, email: str, password: str):
|
||||
page = client.get("/account-login")
|
||||
csrf = extract_csrf(page.text)
|
||||
return client.post(
|
||||
"/account-login",
|
||||
data={"csrf_token": csrf, "email": email, "password": password},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def test_login_with_mywhoosh_credentials_succeeds(client: TestClient) -> None:
|
||||
create_user_via_admin(client)
|
||||
|
||||
response = account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/account"
|
||||
|
||||
|
||||
def test_login_with_garmin_credentials_succeeds(client: TestClient) -> None:
|
||||
create_user_via_admin(client)
|
||||
|
||||
response = account_login(client, email="max@garmin.example", password="garmin-secret")
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/account"
|
||||
|
||||
|
||||
def test_login_with_wrong_password_is_rejected(client: TestClient) -> None:
|
||||
create_user_via_admin(client)
|
||||
|
||||
response = account_login(client, email="max@mywhoosh.example", password="wrong")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid email or password" in response.text
|
||||
|
||||
|
||||
def test_login_never_makes_the_stored_password_appear_in_response(client: TestClient) -> None:
|
||||
create_user_via_admin(client)
|
||||
|
||||
response = account_login(client, email="max@mywhoosh.example", password="wrong")
|
||||
|
||||
assert "mw-secret" not in response.text
|
||||
|
||||
|
||||
def test_account_detail_requires_login(client: TestClient) -> None:
|
||||
response = client.get("/account", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/account-login"
|
||||
|
||||
|
||||
def test_account_detail_shows_own_status_only(client: TestClient) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
|
||||
response = client.get("/account")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Max" in response.text
|
||||
assert "mw-secret" not in response.text
|
||||
assert "garmin-secret" not in response.text
|
||||
|
||||
|
||||
def test_account_edit_page_prefills_emails_not_passwords(client: TestClient) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
|
||||
response = client.get("/account/edit")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "max@mywhoosh.example" in response.text
|
||||
assert "max@garmin.example" in response.text
|
||||
assert "mw-secret" not in response.text
|
||||
assert "garmin-secret" not in response.text
|
||||
|
||||
|
||||
def test_account_update_blank_password_preserves_existing_password(client: TestClient) -> None:
|
||||
user_id = create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
|
||||
edit_page = client.get("/account/edit")
|
||||
csrf = extract_csrf(edit_page.text)
|
||||
response = client.post(
|
||||
"/account/edit",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"mywhoosh_email": "max@mywhoosh.example",
|
||||
"mywhoosh_password": "",
|
||||
"garmin_email": "max@garmin.example",
|
||||
"garmin_password": "",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
with client.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
|
||||
assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret"
|
||||
assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
|
||||
|
||||
|
||||
def test_account_update_can_set_new_password(client: TestClient) -> None:
|
||||
user_id = create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
|
||||
edit_page = client.get("/account/edit")
|
||||
csrf = extract_csrf(edit_page.text)
|
||||
response = client.post(
|
||||
"/account/edit",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"mywhoosh_email": "max@mywhoosh.example",
|
||||
"mywhoosh_password": "new-mw-secret",
|
||||
"garmin_email": "max@garmin.example",
|
||||
"garmin_password": "",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
with client.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
|
||||
assert cipher.decrypt(user.mywhoosh_password_enc) == "new-mw-secret"
|
||||
|
||||
|
||||
def test_account_update_cannot_change_name_or_enabled(client: TestClient) -> None:
|
||||
"""Self-service editing must not expose name/enabled -- those stay
|
||||
administrative decisions, not something the account owner can flip."""
|
||||
user_id = create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
|
||||
edit_page = client.get("/account/edit")
|
||||
csrf = extract_csrf(edit_page.text)
|
||||
client.post(
|
||||
"/account/edit",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"mywhoosh_email": "max@mywhoosh.example",
|
||||
"mywhoosh_password": "",
|
||||
"garmin_email": "max@garmin.example",
|
||||
"garmin_password": "",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
with client.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
assert user.name == "Max"
|
||||
assert user.enabled is True
|
||||
|
||||
|
||||
def test_account_update_persists_notification_preferences(client: TestClient) -> None:
|
||||
user_id = create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
|
||||
edit_page = client.get("/account/edit")
|
||||
csrf = extract_csrf(edit_page.text)
|
||||
response = client.post(
|
||||
"/account/edit",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"mywhoosh_email": "max@mywhoosh.example",
|
||||
"mywhoosh_password": "",
|
||||
"garmin_email": "max@garmin.example",
|
||||
"garmin_password": "",
|
||||
"notify_email_enabled": "on",
|
||||
"notification_email": "alerts@example.com",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
with client.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
assert user.notify_email_enabled is True
|
||||
assert user.notification_email == "alerts@example.com"
|
||||
|
||||
|
||||
def test_account_edit_rejects_invalid_csrf(client: TestClient) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
|
||||
response = client.post(
|
||||
"/account/edit",
|
||||
data={
|
||||
"csrf_token": "invalid-token",
|
||||
"mywhoosh_email": "max@mywhoosh.example",
|
||||
"mywhoosh_password": "",
|
||||
"garmin_email": "max@garmin.example",
|
||||
"garmin_password": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_logout_clears_session(client: TestClient) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
|
||||
page = client.get("/account")
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post("/account-logout", data={"csrf_token": csrf}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
response = client.get("/account", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/account-login"
|
||||
|
||||
|
||||
def test_cannot_view_other_users_account(client: TestClient) -> None:
|
||||
"""Each self-service session is bound to the user_id captured at login;
|
||||
another user created afterwards must not be reachable from it."""
|
||||
create_user_via_admin(client, name="Max")
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
|
||||
with client.app.state.session_factory() as session:
|
||||
UserRepository(session).create(
|
||||
name="Other",
|
||||
enabled=True,
|
||||
mywhoosh_email_enc="unused",
|
||||
mywhoosh_password_enc="unused",
|
||||
garmin_email_enc="unused",
|
||||
garmin_password_enc="unused",
|
||||
)
|
||||
|
||||
response = client.get("/account")
|
||||
assert response.status_code == 200
|
||||
assert "Max" in response.text
|
||||
assert "Other" not in response.text
|
||||
|
||||
|
||||
def test_account_sync_triggers_own_user_only(app, client: TestClient, fake_sync_manager) -> None:
|
||||
user_id = create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
app.state.sync_manager = fake_sync_manager
|
||||
|
||||
page = client.get("/account")
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post("/account/sync", data={"csrf_token": csrf})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert fake_sync_manager.user_calls == [user_id]
|
||||
|
||||
|
||||
def test_account_sync_reports_already_running_as_toast(app, client: TestClient, fake_sync_manager) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
app.state.sync_manager = fake_sync_manager
|
||||
fake_sync_manager.raise_already_running = True
|
||||
|
||||
page = client.get("/account")
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post("/account/sync", data={"csrf_token": csrf})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "already running" in response.text.lower()
|
||||
|
||||
|
||||
def test_account_sync_updates_status_block_and_shows_toast(app, client: TestClient, fake_sync_manager) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
app.state.sync_manager = fake_sync_manager
|
||||
|
||||
page = client.get("/account")
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post("/account/sync", data={"csrf_token": csrf})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'id="account-status"' in response.text
|
||||
assert 'hx-swap-oob="true"' in response.text
|
||||
assert "0 imported, 0 failed" in response.text
|
||||
|
||||
|
||||
def test_account_sync_requires_login(client: TestClient) -> None:
|
||||
response = client.post("/account/sync", data={"csrf_token": "whatever"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/account-login"
|
||||
|
||||
|
||||
def test_account_sync_rejects_invalid_csrf(app, client: TestClient, fake_sync_manager) -> None:
|
||||
create_user_via_admin(client)
|
||||
account_login(client, email="max@mywhoosh.example", password="mw-secret")
|
||||
app.state.sync_manager = fake_sync_manager
|
||||
|
||||
response = client.post("/account/sync", data={"csrf_token": "invalid-token"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert fake_sync_manager.user_calls == []
|
||||
43
tests/web/test_dashboard_summary.py
Normal file
43
tests/web/test_dashboard_summary.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.db.models import HealthState, SyncRunStatus
|
||||
from app.db.repositories import SyncRunRepository, UserRepository
|
||||
|
||||
|
||||
def _seed_user(session, name, *, enabled=True, health_state=HealthState.HEALTHY):
|
||||
return UserRepository(session).create(
|
||||
name=name,
|
||||
enabled=enabled,
|
||||
health_state=health_state,
|
||||
mywhoosh_email_enc=f"mw-{name}",
|
||||
mywhoosh_password_enc=f"mw-pw-{name}",
|
||||
garmin_email_enc=f"g-{name}",
|
||||
garmin_password_enc=f"g-pw-{name}",
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_shows_summary_tiles(app, authenticated_client) -> None:
|
||||
with app.state.session_factory() as session:
|
||||
alex = _seed_user(session, "Alex", enabled=True)
|
||||
_seed_user(session, "Jamie", enabled=False, health_state=HealthState.ACTION_REQUIRED)
|
||||
|
||||
run_repo = SyncRunRepository(session)
|
||||
run = run_repo.start(alex.id)
|
||||
run.started_at = datetime.now(timezone.utc) - timedelta(hours=2)
|
||||
run_repo.finish(run.id, status=SyncRunStatus.SUCCESS, discovered=3, imported=3, skipped=0, failed=0)
|
||||
|
||||
response = authenticated_client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<span class="stat-tile-value">2</span>' in response.text
|
||||
assert "1 enabled" in response.text
|
||||
assert '<span class="stat-tile-value">3</span>' in response.text
|
||||
assert '<span class="stat-tile-value">100%</span>' in response.text
|
||||
assert '<span class="stat-tile-value">1</span>' in response.text
|
||||
|
||||
|
||||
def test_dashboard_shows_dash_for_success_rate_without_recent_runs(authenticated_client) -> None:
|
||||
response = authenticated_client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<span class="stat-tile-value">–</span>' in response.text
|
||||
139
tests/web/test_mfa.py
Normal file
139
tests/web/test_mfa.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.models import Activity, ActivityStatus
|
||||
|
||||
|
||||
def test_mfa_code_is_used_once_and_not_persisted(authenticated_client, fake_sync_manager, app, caplog) -> None:
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
response = authenticated_client.post(
|
||||
"/users/1/garmin-mfa",
|
||||
data={"csrf_token": authenticated_client.csrf_token, "code": "123456"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert fake_sync_manager.mfa_calls == [(1, "123456")]
|
||||
|
||||
# Defense-in-depth: the MFA code must never end up written to the real
|
||||
# app database (the one authenticated_client's requests actually hit),
|
||||
# not some unrelated in-memory db.
|
||||
with app.state.session_factory() as session:
|
||||
persisted_text = " ".join(str(row) for row in session.execute(text("select * from sync_runs")).all())
|
||||
assert "123456" not in persisted_text
|
||||
|
||||
# The check that actually matters: the code must never be logged,
|
||||
# regardless of which SyncManager implementation is in play.
|
||||
assert "123456" not in caplog.text
|
||||
|
||||
|
||||
def test_mfa_code_rejects_empty_code(authenticated_client, fake_sync_manager) -> None:
|
||||
response = authenticated_client.post(
|
||||
"/users/1/garmin-mfa",
|
||||
data={"csrf_token": authenticated_client.csrf_token, "code": " "},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert fake_sync_manager.mfa_calls == []
|
||||
|
||||
|
||||
def test_mfa_code_rejects_overlong_code(authenticated_client, fake_sync_manager) -> None:
|
||||
response = authenticated_client.post(
|
||||
"/users/1/garmin-mfa",
|
||||
data={"csrf_token": authenticated_client.csrf_token, "code": "1" * 21},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert fake_sync_manager.mfa_calls == []
|
||||
|
||||
|
||||
def _seed_activity(app, *, status: ActivityStatus, last_completed_stage: ActivityStatus, retryable: bool, last_error: str | None = None) -> tuple[int, int]:
|
||||
"""Seed a real Activity (and its owning user) in the app fixture's actual
|
||||
database -- the same database authenticated_client's HTTP requests hit --
|
||||
and return (user_id, activity_id)."""
|
||||
from app.db.repositories import ActivityRepository, UserRepository
|
||||
|
||||
with app.state.session_factory() as session:
|
||||
user = UserRepository(session).create(
|
||||
name="MFA Test User",
|
||||
enabled=True,
|
||||
mywhoosh_email_enc="mw@example.com",
|
||||
mywhoosh_password_enc="mw-pass",
|
||||
garmin_email_enc="garmin@example.com",
|
||||
garmin_password_enc="garmin-pass",
|
||||
)
|
||||
activity_repo = ActivityRepository(session)
|
||||
activity, _ = activity_repo.get_or_create_discovered(
|
||||
user_id=user.id,
|
||||
mywhoosh_activity_id="mw-activity-1",
|
||||
activity_name="Test Activity",
|
||||
activity_timestamp=None,
|
||||
)
|
||||
activity.status = status
|
||||
activity.last_completed_stage = last_completed_stage
|
||||
activity.retryable = retryable
|
||||
if last_error is not None:
|
||||
activity.last_error = last_error
|
||||
session.commit()
|
||||
return user.id, activity.id
|
||||
|
||||
|
||||
def test_retry_resets_and_calls_sync_for_retryable_failed_activity(authenticated_client, fake_sync_manager, app) -> None:
|
||||
user_id, activity_id = _seed_activity(
|
||||
app,
|
||||
status=ActivityStatus.FAILED,
|
||||
last_completed_stage=ActivityStatus.CONVERTED,
|
||||
retryable=True,
|
||||
last_error="some transient error",
|
||||
)
|
||||
|
||||
response = authenticated_client.post(
|
||||
f"/activities/{activity_id}/retry",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert fake_sync_manager.user_calls == [user_id]
|
||||
|
||||
with app.state.session_factory() as session:
|
||||
reloaded = session.get(Activity, activity_id)
|
||||
assert reloaded.status == ActivityStatus.CONVERTED
|
||||
assert reloaded.last_error is None
|
||||
|
||||
|
||||
def test_retry_rejects_non_retryable_activity(authenticated_client, fake_sync_manager, app) -> None:
|
||||
user_id, activity_id = _seed_activity(
|
||||
app,
|
||||
status=ActivityStatus.FAILED,
|
||||
last_completed_stage=ActivityStatus.CONVERTED,
|
||||
retryable=False,
|
||||
last_error="permanent failure",
|
||||
)
|
||||
|
||||
response = authenticated_client.post(
|
||||
f"/activities/{activity_id}/retry",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert fake_sync_manager.user_calls == []
|
||||
|
||||
with app.state.session_factory() as session:
|
||||
reloaded = session.get(Activity, activity_id)
|
||||
assert reloaded.status == ActivityStatus.FAILED
|
||||
assert reloaded.retryable is False
|
||||
assert reloaded.last_error == "permanent failure"
|
||||
|
||||
|
||||
def test_retry_rejects_non_failed_activity(authenticated_client, fake_sync_manager, app) -> None:
|
||||
user_id, activity_id = _seed_activity(
|
||||
app,
|
||||
status=ActivityStatus.DISCOVERED,
|
||||
last_completed_stage=ActivityStatus.DISCOVERED,
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
response = authenticated_client.post(
|
||||
f"/activities/{activity_id}/retry",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert fake_sync_manager.user_calls == []
|
||||
36
tests/web/test_next_sync_display.py
Normal file
36
tests/web/test_next_sync_display.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class _FakeScheduler:
|
||||
def __init__(self, next_tick=None) -> None:
|
||||
self.last_tick = None
|
||||
self.next_tick = next_tick
|
||||
|
||||
|
||||
def test_login_page_shows_next_sync_time(app, client: TestClient) -> None:
|
||||
next_tick = datetime(2026, 8, 16, 14, 32, tzinfo=timezone.utc)
|
||||
app.state.scheduler = _FakeScheduler(next_tick=next_tick)
|
||||
|
||||
response = client.get("/login")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-utc="2026-08-16T14:32:00+00:00"' in response.text
|
||||
|
||||
|
||||
def test_dashboard_shows_next_sync_time(app, authenticated_client) -> None:
|
||||
next_tick = datetime(2026, 8, 16, 15, 0, tzinfo=timezone.utc)
|
||||
app.state.scheduler = _FakeScheduler(next_tick=next_tick)
|
||||
|
||||
response = authenticated_client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-utc="2026-08-16T15:00:00+00:00"' in response.text
|
||||
|
||||
|
||||
def test_login_page_renders_without_scheduler(client: TestClient) -> None:
|
||||
response = client.get("/login")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "data-utc" not in response.text
|
||||
260
tests/web/test_operations.py
Normal file
260
tests/web/test_operations.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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:
|
||||
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_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 == 200
|
||||
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_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",
|
||||
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
|
||||
|
||||
|
||||
class _FakeScheduler:
|
||||
def __init__(self) -> None:
|
||||
self.last_tick = None
|
||||
self.next_tick = None
|
||||
|
||||
|
||||
def test_system_page_shows_empty_log_state(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
|
||||
response = authenticated_client.get("/system")
|
||||
assert response.status_code == 200
|
||||
assert "No system log entries" in response.text
|
||||
|
||||
|
||||
def test_system_page_shows_recorded_log_entries(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
with app.state.session_factory() as session:
|
||||
SystemLogRepository(session).add(
|
||||
source="email_notification", message="Failed to email alerts@example.com: SMTP timeout"
|
||||
)
|
||||
|
||||
response = authenticated_client.get("/system")
|
||||
assert response.status_code == 200
|
||||
assert "email_notification" in response.text
|
||||
assert "SMTP timeout" in response.text
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def test_scheduler_settings_page_prefills_defaults(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
|
||||
response = authenticated_client.get("/system")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'id="day_start_hour"' in response.text
|
||||
assert 'value="6"' in response.text
|
||||
assert 'value="22"' in response.text
|
||||
|
||||
|
||||
def test_update_scheduler_settings_persists_and_takes_effect_next_tick(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
page = authenticated_client.get("/system")
|
||||
csrf = _extract_csrf(page.text)
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/system/scheduler-settings",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"day_start_hour": "8",
|
||||
"night_start_hour": "20",
|
||||
"day_interval_minutes": "3",
|
||||
"night_interval_minutes": "45",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/system"
|
||||
|
||||
with app.state.session_factory() as session:
|
||||
row = SchedulerSettingsRepository(session).get_or_create(default_minutes=5)
|
||||
assert row.day_start_hour == 8
|
||||
assert row.night_start_hour == 20
|
||||
assert row.day_interval_minutes == 3
|
||||
assert row.night_interval_minutes == 45
|
||||
|
||||
|
||||
def test_update_scheduler_settings_rejects_out_of_range_hour(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
page = authenticated_client.get("/system")
|
||||
csrf = _extract_csrf(page.text)
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/system/scheduler-settings",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"day_start_hour": "24",
|
||||
"night_start_hour": "22",
|
||||
"day_interval_minutes": "5",
|
||||
"night_interval_minutes": "5",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_update_scheduler_settings_rejects_non_positive_interval(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
page = authenticated_client.get("/system")
|
||||
csrf = _extract_csrf(page.text)
|
||||
|
||||
response = authenticated_client.post(
|
||||
"/system/scheduler-settings",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"day_start_hour": "6",
|
||||
"night_start_hour": "22",
|
||||
"day_interval_minutes": "0",
|
||||
"night_interval_minutes": "5",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_update_scheduler_settings_requires_admin(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/system/scheduler-settings",
|
||||
data={
|
||||
"csrf_token": "whatever",
|
||||
"day_start_hour": "6",
|
||||
"night_start_hour": "22",
|
||||
"day_interval_minutes": "5",
|
||||
"night_interval_minutes": "5",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_update_scheduler_settings_rejects_invalid_csrf(app, authenticated_client) -> None:
|
||||
app.state.scheduler = _FakeScheduler()
|
||||
response = authenticated_client.post(
|
||||
"/system/scheduler-settings",
|
||||
data={
|
||||
"csrf_token": "invalid-token",
|
||||
"day_start_hour": "6",
|
||||
"night_start_hour": "22",
|
||||
"day_interval_minutes": "5",
|
||||
"night_interval_minutes": "5",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
12
tests/web/test_static_cache_busting.py
Normal file
12
tests/web/test_static_cache_busting.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_static_assets_are_served_with_a_cache_busting_version(client: TestClient) -> None:
|
||||
response = client.get("/login")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert re.search(r'/static/style\.css\?v=[0-9a-f]{6,}"', response.text)
|
||||
assert re.search(r'/static/app\.js\?v=[0-9a-f]{6,}"', response.text)
|
||||
assert re.search(r'/static/htmx\.min\.js\?v=[0-9a-f]{6,}"', response.text)
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db.repositories import UserRepository
|
||||
from app.db.models import SyncRunStatus
|
||||
from app.db.repositories import SyncRunRepository, UserRepository
|
||||
from app.security.credentials import CredentialCipher
|
||||
|
||||
|
||||
@@ -171,6 +172,30 @@ def test_user_detail_page_shows_no_secrets(client: TestClient) -> None:
|
||||
assert "Max" in response.text
|
||||
|
||||
|
||||
def test_user_detail_page_shows_recent_sync_runs(client: TestClient) -> None:
|
||||
login(client)
|
||||
user_id = create_user_via_http(client)
|
||||
|
||||
with client.app.state.session_factory() as session:
|
||||
repo = SyncRunRepository(session)
|
||||
run = repo.start(user_id)
|
||||
repo.finish(
|
||||
run.id,
|
||||
status=SyncRunStatus.SUCCESS,
|
||||
discovered=3,
|
||||
imported=2,
|
||||
skipped=1,
|
||||
failed=0,
|
||||
)
|
||||
|
||||
response = client.get(f"/users/{user_id}")
|
||||
assert response.status_code == 200
|
||||
assert "Recent sync runs" in response.text
|
||||
assert "success" in response.text
|
||||
assert ">3<" in response.text
|
||||
assert ">2<" in response.text
|
||||
|
||||
|
||||
def test_unknown_user_returns_404_for_detail(client: TestClient) -> None:
|
||||
login(client)
|
||||
response = client.get("/users/999999")
|
||||
@@ -341,6 +366,106 @@ def test_update_user_rejects_empty_mywhoosh_email(client: TestClient) -> None:
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_create_user_without_notifications_requires_no_email(client: TestClient) -> None:
|
||||
login(client)
|
||||
page = client.get("/users/new")
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post(
|
||||
"/users",
|
||||
data={"csrf_token": csrf, **_create_payload()},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def test_create_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
|
||||
login(client)
|
||||
page = client.get("/users/new")
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post(
|
||||
"/users",
|
||||
data={"csrf_token": csrf, **_create_payload(notify_email_enabled="on")},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_create_user_persists_notification_preferences(client: TestClient) -> None:
|
||||
login(client)
|
||||
page = client.get("/users/new")
|
||||
csrf = extract_csrf(page.text)
|
||||
response = client.post(
|
||||
"/users",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
**_create_payload(notify_email_enabled="on", notification_email="alerts@example.com"),
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
user_id = int(response.headers["location"].rsplit("/", 1)[-1])
|
||||
|
||||
with client.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
assert user is not None
|
||||
assert user.notify_email_enabled is True
|
||||
assert user.notification_email == "alerts@example.com"
|
||||
|
||||
|
||||
def test_update_user_can_enable_notifications_without_reentering_passwords(client: TestClient) -> None:
|
||||
login(client)
|
||||
user_id = create_user_via_http(client)
|
||||
|
||||
edit_page = client.get(f"/users/{user_id}/edit")
|
||||
csrf = extract_csrf(edit_page.text)
|
||||
response = client.post(
|
||||
f"/users/{user_id}",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"name": "Max",
|
||||
"mywhoosh_email": "max@example.com",
|
||||
"mywhoosh_password": "",
|
||||
"garmin_email": "max-garmin@example.com",
|
||||
"garmin_password": "",
|
||||
"enabled": "on",
|
||||
"notify_email_enabled": "on",
|
||||
"notification_email": "alerts@example.com",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with client.app.state.session_factory() as session:
|
||||
user = UserRepository(session).get(user_id)
|
||||
assert user is not None
|
||||
assert user.notify_email_enabled is True
|
||||
assert user.notification_email == "alerts@example.com"
|
||||
cipher = CredentialCipher(client.app.state.settings.credential_encryption_key)
|
||||
assert cipher.decrypt(user.mywhoosh_password_enc) == "mw-secret"
|
||||
assert cipher.decrypt(user.garmin_password_enc) == "garmin-secret"
|
||||
|
||||
|
||||
def test_update_user_rejects_notify_enabled_without_email(client: TestClient) -> None:
|
||||
login(client)
|
||||
user_id = create_user_via_http(client)
|
||||
edit_page = client.get(f"/users/{user_id}/edit")
|
||||
csrf = extract_csrf(edit_page.text)
|
||||
response = client.post(
|
||||
f"/users/{user_id}",
|
||||
data={
|
||||
"csrf_token": csrf,
|
||||
"name": "Max",
|
||||
"mywhoosh_email": "max@example.com",
|
||||
"mywhoosh_password": "",
|
||||
"garmin_email": "max-garmin@example.com",
|
||||
"garmin_password": "",
|
||||
"enabled": "on",
|
||||
"notify_email_enabled": "on",
|
||||
"notification_email": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_update_user_rejects_empty_garmin_email(client: TestClient) -> None:
|
||||
login(client)
|
||||
user_id = create_user_via_http(client)
|
||||
|
||||
Reference in New Issue
Block a user