feat: Initial commit of FitTrack_GarminSync project

This commit is contained in:
2025-10-10 12:20:48 -07:00
parent d0e29fbeb4
commit 18f9f6fa18
229 changed files with 21035 additions and 42 deletions

View File

@@ -0,0 +1,234 @@
"""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"
}