mirror of
https://github.com/sstent/FitTrack_ReportGenerator.git
synced 2026-01-27 09:32:12 +00:00
This commit introduces the initial version of the FitTrack Report Generator, a FastAPI application for analyzing workout files. Key features include: - Parsing of FIT, TCX, and GPX workout files. - Analysis of power, heart rate, speed, and elevation data. - Generation of summary reports and charts. - REST API for single and batch workout analysis. The project structure has been set up with a `src` directory for core logic, an `api` directory for the FastAPI application, and a `tests` directory for unit, integration, and contract tests. The development workflow is configured to use Docker and modern Python tooling.
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"
|
|
}
|