Files
mywhoosh2garmin/app/db/repositories.py
Bastian Wagner 420d089760 log
2026-08-15 20:54:35 +02:00

260 lines
9.1 KiB
Python

from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import and_, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.db.models import Activity, ActivityStatus, SyncRun, SyncRunStatus, SystemLogEntry, SyncUser, utcnow
@dataclass(frozen=True)
class UserDashboardRow:
id: int
name: str
enabled: bool
health_state: str
action_reason: str | None
last_sync_at: datetime | None
last_activity_name: str | None
last_activity_status: str | None
class UserRepository:
def __init__(self, session: Session) -> None:
self.session = session
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]:
users = self.list_all()
rows = []
for user in users:
last_run = self.session.scalar(
select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1)
)
last_activity = self.session.scalar(
select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1)
)
rows.append(UserDashboardRow(
id=user.id,
name=user.name,
enabled=user.enabled,
health_state=user.health_state.value,
action_reason=user.action_reason,
last_sync_at=last_run.finished_at if last_run else None,
last_activity_name=last_activity.activity_name if last_activity else None,
last_activity_status=last_activity.status.value if last_activity else None,
))
return rows
class ActivityRepository:
def __init__(self, session: Session) -> None:
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 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