mirror of
https://github.com/sstent/GarminSync.git
synced 2026-01-25 08:35:02 +00:00
235 lines
8.3 KiB
Python
235 lines
8.3 KiB
Python
"""Database module for GarminSync application with async support."""
|
|
|
|
import os
|
|
from datetime import datetime
|
|
from contextlib import asynccontextmanager
|
|
|
|
from sqlalchemy import Boolean, Column, Float, Integer, String
|
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
from sqlalchemy.future import select
|
|
from sqlalchemy.orm import declarative_base
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from sqlalchemy.orm import selectinload, joinedload
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
Base = declarative_base()
|
|
|
|
class Activity(Base):
|
|
"""Activity model representing a Garmin activity record."""
|
|
|
|
__tablename__ = "activities"
|
|
|
|
activity_id = Column(Integer, primary_key=True)
|
|
start_time = Column(String, nullable=False)
|
|
activity_type = Column(String, nullable=True)
|
|
duration = Column(Integer, nullable=True)
|
|
distance = Column(Float, nullable=True)
|
|
max_heart_rate = Column(Integer, nullable=True)
|
|
avg_heart_rate = Column(Integer, nullable=True)
|
|
avg_power = Column(Float, nullable=True)
|
|
calories = Column(Integer, nullable=True)
|
|
filename = Column(String, unique=True, nullable=True)
|
|
downloaded = Column(Boolean, default=False, nullable=False)
|
|
reprocessed = Column(Boolean, default=False, nullable=False)
|
|
created_at = Column(String, nullable=False)
|
|
last_sync = Column(String, nullable=True)
|
|
|
|
@classmethod
|
|
async def get_paginated(cls, db, page=1, per_page=10):
|
|
"""Get paginated list of activities (async)."""
|
|
async with db.begin() as session:
|
|
query = select(cls).order_by(cls.start_time.desc())
|
|
result = await session.execute(query.offset((page-1)*per_page).limit(per_page))
|
|
activities = result.scalars().all()
|
|
count_result = await session.execute(select(select(cls).count()))
|
|
total = count_result.scalar_one()
|
|
return {
|
|
"items": activities,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"total": total,
|
|
"pages": (total + per_page - 1) // per_page
|
|
}
|
|
|
|
def to_dict(self):
|
|
"""Convert activity to dictionary representation."""
|
|
return {
|
|
"id": self.activity_id,
|
|
"name": self.filename or "Unnamed Activity",
|
|
"distance": self.distance,
|
|
"duration": self.duration,
|
|
"start_time": self.start_time,
|
|
"activity_type": self.activity_type,
|
|
"max_heart_rate": self.max_heart_rate,
|
|
"avg_heart_rate": self.avg_heart_rate,
|
|
"avg_power": self.avg_power,
|
|
"calories": self.calories,
|
|
}
|
|
|
|
|
|
class DaemonConfig(Base):
|
|
"""Daemon configuration model."""
|
|
|
|
__tablename__ = "daemon_config"
|
|
|
|
id = Column(Integer, primary_key=True, default=1)
|
|
enabled = Column(Boolean, default=True, nullable=False)
|
|
schedule_cron = Column(String, default="0 */6 * * *", nullable=False)
|
|
last_run = Column(String, nullable=True)
|
|
next_run = Column(String, nullable=True)
|
|
status = Column(String, default="stopped", nullable=False)
|
|
|
|
@classmethod
|
|
async def get(cls, db):
|
|
"""Get configuration record (async)."""
|
|
async with db.begin() as session:
|
|
result = await session.execute(select(cls))
|
|
return result.scalars().first()
|
|
|
|
|
|
class SyncLog(Base):
|
|
"""Sync log model for tracking sync operations."""
|
|
|
|
__tablename__ = "sync_logs"
|
|
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
timestamp = Column(String, nullable=False)
|
|
operation = Column(String, nullable=False)
|
|
status = Column(String, nullable=False)
|
|
message = Column(String, nullable=True)
|
|
activities_processed = Column(Integer, default=0, nullable=False)
|
|
activities_downloaded = Column(Integer, default=0, nullable=False)
|
|
|
|
|
|
# Database initialization and session management
|
|
engine = None
|
|
async_session = None
|
|
|
|
async def init_db():
|
|
"""Initialize database connection and create tables."""
|
|
global engine, async_session
|
|
db_path = os.getenv("DB_PATH", "data/garmin.db")
|
|
engine = create_async_engine(
|
|
f"sqlite+aiosqlite:///{db_path}",
|
|
pool_size=10,
|
|
max_overflow=20,
|
|
pool_pre_ping=True
|
|
)
|
|
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
# Create tables if they don't exist
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def get_db():
|
|
"""Async context manager for database sessions."""
|
|
async with async_session() as session:
|
|
try:
|
|
yield session
|
|
await session.commit()
|
|
except SQLAlchemyError:
|
|
await session.rollback()
|
|
raise
|
|
|
|
|
|
# Compatibility layer for legacy sync functions
|
|
def get_legacy_session():
|
|
"""Temporary synchronous session for migration purposes."""
|
|
db_path = os.getenv("DB_PATH", "data/garmin.db")
|
|
sync_engine = create_engine(f"sqlite:///{db_path}")
|
|
Base.metadata.create_all(sync_engine)
|
|
Session = sessionmaker(bind=sync_engine)
|
|
return Session()
|
|
|
|
|
|
async def sync_database(garmin_client):
|
|
"""Sync local database with Garmin Connect activities (async)."""
|
|
from garminsync.activity_parser import get_activity_metrics
|
|
async with get_db() as session:
|
|
try:
|
|
activities = garmin_client.get_activities(0, 1000)
|
|
|
|
if not activities:
|
|
print("No activities returned from Garmin API")
|
|
return
|
|
|
|
for activity_data in activities:
|
|
if not isinstance(activity_data, dict):
|
|
print(f"Invalid activity data: {activity_data}")
|
|
continue
|
|
|
|
activity_id = activity_data.get("activityId")
|
|
start_time = activity_data.get("startTimeLocal")
|
|
|
|
if not activity_id or not start_time:
|
|
print(f"Missing required fields in activity: {activity_data}")
|
|
continue
|
|
|
|
result = await session.execute(
|
|
select(Activity).filter_by(activity_id=activity_id)
|
|
)
|
|
existing = result.scalars().first()
|
|
|
|
# Create or update basic activity info
|
|
if not existing:
|
|
activity = Activity(
|
|
activity_id=activity_id,
|
|
start_time=start_time,
|
|
downloaded=False,
|
|
created_at=datetime.now().isoformat(),
|
|
last_sync=datetime.now().isoformat(),
|
|
)
|
|
session.add(activity)
|
|
else:
|
|
activity = existing
|
|
|
|
# Update metrics using shared parser
|
|
metrics = get_activity_metrics(activity, garmin_client)
|
|
if metrics:
|
|
activity.activity_type = metrics.get("activityType", {}).get("typeKey")
|
|
# ... rest of metric processing ...
|
|
|
|
# Update sync timestamp
|
|
activity.last_sync = datetime.now().isoformat()
|
|
|
|
await session.commit()
|
|
except SQLAlchemyError as e:
|
|
await session.rollback()
|
|
raise e
|
|
|
|
|
|
async def get_offline_stats():
|
|
"""Return statistics about cached data without API calls (async)."""
|
|
async with get_db() as session:
|
|
try:
|
|
result = await session.execute(select(Activity))
|
|
total = len(result.scalars().all())
|
|
|
|
result = await session.execute(
|
|
select(Activity).filter_by(downloaded=True)
|
|
)
|
|
downloaded = len(result.scalars().all())
|
|
|
|
result = await session.execute(
|
|
select(Activity).order_by(Activity.last_sync.desc())
|
|
)
|
|
last_sync = result.scalars().first()
|
|
|
|
return {
|
|
"total": total,
|
|
"downloaded": downloaded,
|
|
"missing": total - downloaded,
|
|
"last_sync": last_sync.last_sync if last_sync else "Never synced",
|
|
}
|
|
except SQLAlchemyError as e:
|
|
print(f"Database error: {e}")
|
|
return {
|
|
"total": 0,
|
|
"downloaded": 0,
|
|
"missing": 0,
|
|
"last_sync": "Error"
|
|
}
|