from dataclasses import dataclass from datetime import datetime from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session 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: def __init__(self, session: Session) -> None: self.session = session def create(self, **values) -> SyncUser: user = SyncUser(**values) self.session.add(user) self.session.commit() return user def get(self, user_id: int) -> SyncUser | None: return self.session.get(SyncUser, user_id) def list_enabled(self) -> list[SyncUser]: return list(self.session.scalars(select(SyncUser).where(SyncUser.enabled.is_(True)).order_by(SyncUser.id))) def list_all(self) -> list[SyncUser]: return list(self.session.scalars(select(SyncUser).order_by(SyncUser.name))) def update(self, user: SyncUser, **values) -> SyncUser: for key, value in values.items(): setattr(user, key, value) 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, *, user_id: int, mywhoosh_activity_id: str, activity_name: str, activity_timestamp: datetime | None, ) -> tuple[Activity, bool]: existing = self.session.scalar( select(Activity).where( Activity.user_id == user_id, Activity.mywhoosh_activity_id == mywhoosh_activity_id, ) ) if existing is not None: return existing, False activity = Activity( user_id=user_id, mywhoosh_activity_id=mywhoosh_activity_id, activity_name=activity_name, activity_timestamp=activity_timestamp, status=ActivityStatus.DISCOVERED, last_completed_stage=ActivityStatus.DISCOVERED, ) self.session.add(activity) try: self.session.commit() except IntegrityError: self.session.rollback() existing = self.session.scalar( select(Activity).where( Activity.user_id == user_id, Activity.mywhoosh_activity_id == mywhoosh_activity_id, ) ) if existing is None: 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