working again stable

This commit is contained in:
2025-08-22 16:36:45 -07:00
parent 9c4e652047
commit 5f0cd85406
14 changed files with 867 additions and 462 deletions

View File

@@ -1,38 +1,39 @@
# Use official Python base image # Use an official Python runtime as a parent image
FROM python:3.10-slim FROM python:3.10-slim
# Set environment variables # Set the working directory
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Set work directory
WORKDIR /app WORKDIR /app
# Install system dependencies # Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential curl git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy and install Python dependencies # Copy requirements file first to leverage Docker cache
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt && \ # Upgrade pip and install Python dependencies
pip install --no-cache-dir alembic RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY garminsync/ ./garminsync/ COPY garminsync/ ./garminsync/
COPY migrations/ ./migrations/ COPY migrations/ ./migrations/
COPY tests/ ./tests/
COPY entrypoint.sh . COPY entrypoint.sh .
COPY patches/ ./patches/
# Fix garth package duplicate parameter issue
RUN cp patches/garth_data_weight.py /usr/local/lib/python3.10/site-packages/garth/data/weight.py
# Make the entrypoint script executable
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
# Create data directory # Create data directory
RUN mkdir -p /app/data RUN mkdir -p /app/data
# Set environment variables from .env file
ENV ENV_FILE=/app/.env
ENV DATA_DIR=/app/data
# Expose web UI port # Set the entrypoint
EXPOSE 8080
# Update entrypoint to support daemon mode
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
CMD ["--help"]
# Expose port
EXPOSE 8888

View File

@@ -12,5 +12,5 @@ fi
# Start the application # Start the application
echo "Starting application..." echo "Starting application..."
exec python -m garminsync.cli daemon --start exec python -m garminsync.cli daemon --start --port 8888
sleep infinity sleep infinity

View File

@@ -1,28 +1,45 @@
import os import os
import typer import typer
from typing_extensions import Annotated from typing_extensions import Annotated
from .config import load_config from .config import load_config
# Initialize environment variables # Initialize environment variables
load_config() load_config()
app = typer.Typer(help="GarminSync - Download Garmin Connect activities", rich_markup_mode=None) app = typer.Typer(
help="GarminSync - Download Garmin Connect activities", rich_markup_mode=None
)
@app.command("list") @app.command("list")
def list_activities( def list_activities(
all_activities: Annotated[bool, typer.Option("--all", help="List all activities")] = False, all_activities: Annotated[
missing: Annotated[bool, typer.Option("--missing", help="List missing activities")] = False, bool, typer.Option("--all", help="List all activities")
downloaded: Annotated[bool, typer.Option("--downloaded", help="List downloaded activities")] = False, ] = False,
offline: Annotated[bool, typer.Option("--offline", help="Work offline without syncing")] = False missing: Annotated[
bool, typer.Option("--missing", help="List missing activities")
] = False,
downloaded: Annotated[
bool, typer.Option("--downloaded", help="List downloaded activities")
] = False,
offline: Annotated[
bool, typer.Option("--offline", help="Work offline without syncing")
] = False,
): ):
"""List activities based on specified filters""" """List activities based on specified filters"""
from tqdm import tqdm from tqdm import tqdm
from .database import get_session, Activity, get_offline_stats, sync_database
from .database import (Activity, get_offline_stats, get_session,
sync_database)
from .garmin import GarminClient from .garmin import GarminClient
# Validate input # Validate input
if not any([all_activities, missing, downloaded]): if not any([all_activities, missing, downloaded]):
typer.echo("Error: Please specify at least one filter option (--all, --missing, --downloaded)") typer.echo(
"Error: Please specify at least one filter option (--all, --missing, --downloaded)"
)
raise typer.Exit(code=1) raise typer.Exit(code=1)
try: try:
@@ -36,7 +53,9 @@ def list_activities(
else: else:
# Show offline status with last sync info # Show offline status with last sync info
stats = get_offline_stats() stats = get_offline_stats()
typer.echo(f"Working in offline mode - using cached data (last sync: {stats['last_sync']})") typer.echo(
f"Working in offline mode - using cached data (last sync: {stats['last_sync']})"
)
# Build query based on filters # Build query based on filters
query = session.query(Activity) query = session.query(Activity)
@@ -58,23 +77,30 @@ def list_activities(
typer.echo(f"Found {len(activities)} activities:") typer.echo(f"Found {len(activities)} activities:")
for activity in tqdm(activities, desc="Listing activities"): for activity in tqdm(activities, desc="Listing activities"):
status = "Downloaded" if activity.downloaded else "Missing" status = "Downloaded" if activity.downloaded else "Missing"
typer.echo(f"- ID: {activity.activity_id}, Start: {activity.start_time}, Status: {status}") typer.echo(
f"- ID: {activity.activity_id}, Start: {activity.start_time}, Status: {status}"
)
except Exception as e: except Exception as e:
typer.echo(f"Error: {str(e)}") typer.echo(f"Error: {str(e)}")
raise typer.Exit(code=1) raise typer.Exit(code=1)
finally: finally:
if 'session' in locals(): if "session" in locals():
session.close() session.close()
@app.command("download") @app.command("download")
def download( def download(
missing: Annotated[bool, typer.Option("--missing", help="Download missing activities")] = False missing: Annotated[
bool, typer.Option("--missing", help="Download missing activities")
] = False,
): ):
"""Download activities based on specified filters""" """Download activities based on specified filters"""
from tqdm import tqdm
from pathlib import Path from pathlib import Path
from .database import get_session, Activity
from tqdm import tqdm
from .database import Activity, get_session
from .garmin import GarminClient from .garmin import GarminClient
# Validate input # Validate input
@@ -89,6 +115,7 @@ def download(
# Sync database with latest activities # Sync database with latest activities
typer.echo("Syncing activities from Garmin Connect...") typer.echo("Syncing activities from Garmin Connect...")
from .database import sync_database from .database import sync_database
sync_database(client) sync_database(client)
# Get missing activities # Get missing activities
@@ -123,7 +150,9 @@ def download(
session.commit() session.commit()
except Exception as e: except Exception as e:
typer.echo(f"Error downloading activity {activity.activity_id}: {str(e)}") typer.echo(
f"Error downloading activity {activity.activity_id}: {str(e)}"
)
session.rollback() session.rollback()
typer.echo("Download completed successfully") typer.echo("Download completed successfully")
@@ -132,15 +161,18 @@ def download(
typer.echo(f"Error: {str(e)}") typer.echo(f"Error: {str(e)}")
raise typer.Exit(code=1) raise typer.Exit(code=1)
finally: finally:
if 'session' in locals(): if "session" in locals():
session.close() session.close()
@app.command("daemon") @app.command("daemon")
def daemon_mode( def daemon_mode(
start: Annotated[bool, typer.Option("--start", help="Start daemon")] = False, start: Annotated[bool, typer.Option("--start", help="Start daemon")] = False,
stop: Annotated[bool, typer.Option("--stop", help="Stop daemon")] = False, stop: Annotated[bool, typer.Option("--stop", help="Stop daemon")] = False,
status: Annotated[bool, typer.Option("--status", help="Show daemon status")] = False, status: Annotated[
port: Annotated[int, typer.Option("--port", help="Web UI port")] = 8080 bool, typer.Option("--status", help="Show daemon status")
] = False,
port: Annotated[int, typer.Option("--port", help="Web UI port")] = 8080,
): ):
"""Daemon mode operations""" """Daemon mode operations"""
from .daemon import GarminSyncDaemon from .daemon import GarminSyncDaemon
@@ -159,6 +191,7 @@ def daemon_mode(
else: else:
typer.echo("Please specify one of: --start, --stop, --status") typer.echo("Please specify one of: --start, --stop, --status")
@app.command("migrate") @app.command("migrate")
def migrate_activities(): def migrate_activities():
"""Migrate database to add new activity fields""" """Migrate database to add new activity fields"""
@@ -172,8 +205,10 @@ def migrate_activities():
typer.echo("Database migration failed!") typer.echo("Database migration failed!")
raise typer.Exit(code=1) raise typer.Exit(code=1)
def main(): def main():
app() app()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,10 +1,13 @@
from dotenv import load_dotenv
import os import os
from dotenv import load_dotenv
def load_config(): def load_config():
"""Load environment variables from .env file""" """Load environment variables from .env file"""
load_dotenv() load_dotenv()
class Config: class Config:
GARMIN_EMAIL = os.getenv("GARMIN_EMAIL") GARMIN_EMAIL = os.getenv("GARMIN_EMAIL")
GARMIN_PASSWORD = os.getenv("GARMIN_PASSWORD") GARMIN_PASSWORD = os.getenv("GARMIN_PASSWORD")

View File

@@ -1,40 +1,45 @@
import signal import signal
import sys import sys
import time
import threading import threading
import time
from datetime import datetime from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from .database import get_session, Activity, DaemonConfig, SyncLog
from .database import Activity, DaemonConfig, SyncLog, get_session
from .garmin import GarminClient from .garmin import GarminClient
from .utils import logger from .utils import logger
class GarminSyncDaemon: class GarminSyncDaemon:
def __init__(self): def __init__(self):
self.scheduler = BackgroundScheduler() self.scheduler = BackgroundScheduler()
self.running = False self.running = False
self.web_server = None self.web_server = None
def start(self, web_port=8080): def start(self, web_port=8888):
"""Start daemon with scheduler and web UI""" """Start daemon with scheduler and web UI"""
try: try:
# Load configuration from database # Load configuration from database
config_data = self.load_config() config_data = self.load_config()
# Setup scheduled job # Setup scheduled job
if config_data['enabled']: if config_data["enabled"]:
cron_str = config_data['schedule_cron'] cron_str = config_data["schedule_cron"]
try: try:
# Validate cron string # Validate cron string
if not cron_str or len(cron_str.strip().split()) != 5: if not cron_str or len(cron_str.strip().split()) != 5:
logger.error(f"Invalid cron schedule: '{cron_str}'. Using default '0 */6 * * *'") logger.error(
f"Invalid cron schedule: '{cron_str}'. Using default '0 */6 * * *'"
)
cron_str = "0 */6 * * *" cron_str = "0 */6 * * *"
self.scheduler.add_job( self.scheduler.add_job(
func=self.sync_and_download, func=self.sync_and_download,
trigger=CronTrigger.from_crontab(cron_str), trigger=CronTrigger.from_crontab(cron_str),
id='sync_job', id="sync_job",
replace_existing=True replace_existing=True,
) )
logger.info(f"Scheduled job created with cron: '{cron_str}'") logger.info(f"Scheduled job created with cron: '{cron_str}'")
except Exception as e: except Exception as e:
@@ -43,8 +48,8 @@ class GarminSyncDaemon:
self.scheduler.add_job( self.scheduler.add_job(
func=self.sync_and_download, func=self.sync_and_download,
trigger=CronTrigger.from_crontab("0 */6 * * *"), trigger=CronTrigger.from_crontab("0 */6 * * *"),
id='sync_job', id="sync_job",
replace_existing=True replace_existing=True,
) )
logger.info("Using default schedule '0 */6 * * *'") logger.info("Using default schedule '0 */6 * * *'")
@@ -62,7 +67,9 @@ class GarminSyncDaemon:
signal.signal(signal.SIGINT, self.signal_handler) signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler) signal.signal(signal.SIGTERM, self.signal_handler)
logger.info(f"Daemon started. Web UI available at http://localhost:{web_port}") logger.info(
f"Daemon started. Web UI available at http://localhost:{web_port}"
)
# Keep daemon running # Keep daemon running
while self.running: while self.running:
@@ -80,8 +87,8 @@ class GarminSyncDaemon:
self.log_operation("sync", "started") self.log_operation("sync", "started")
# Import here to avoid circular imports # Import here to avoid circular imports
from .garmin import GarminClient
from .database import sync_database from .database import sync_database
from .garmin import GarminClient
# Perform sync and download # Perform sync and download
client = GarminClient() client = GarminClient()
@@ -92,7 +99,9 @@ class GarminSyncDaemon:
# Download missing activities # Download missing activities
downloaded_count = 0 downloaded_count = 0
session = get_session() session = get_session()
missing_activities = session.query(Activity).filter_by(downloaded=False).all() missing_activities = (
session.query(Activity).filter_by(downloaded=False).all()
)
for activity in missing_activities: for activity in missing_activities:
try: try:
@@ -102,6 +111,7 @@ class GarminSyncDaemon:
# Save the file # Save the file
import os import os
from pathlib import Path from pathlib import Path
data_dir = Path(os.getenv("DATA_DIR", "data")) data_dir = Path(os.getenv("DATA_DIR", "data"))
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
@@ -119,11 +129,14 @@ class GarminSyncDaemon:
session.commit() session.commit()
except Exception as e: except Exception as e:
logger.error(f"Failed to download activity {activity.activity_id}: {e}") logger.error(
f"Failed to download activity {activity.activity_id}: {e}"
)
session.rollback() session.rollback()
self.log_operation("sync", "success", self.log_operation(
f"Downloaded {downloaded_count} new activities") "sync", "success", f"Downloaded {downloaded_count} new activities"
)
# Update last run time # Update last run time
self.update_daemon_last_run() self.update_daemon_last_run()
@@ -143,9 +156,7 @@ class GarminSyncDaemon:
if not config: if not config:
# Create default configuration with explicit cron schedule # Create default configuration with explicit cron schedule
config = DaemonConfig( config = DaemonConfig(
schedule_cron="0 */6 * * *", schedule_cron="0 */6 * * *", enabled=True, status="stopped"
enabled=True,
status="stopped"
) )
session.add(config) session.add(config)
session.commit() session.commit()
@@ -153,12 +164,12 @@ class GarminSyncDaemon:
# Return configuration as dictionary to avoid session issues # Return configuration as dictionary to avoid session issues
return { return {
'id': config.id, "id": config.id,
'enabled': config.enabled, "enabled": config.enabled,
'schedule_cron': config.schedule_cron, "schedule_cron": config.schedule_cron,
'last_run': config.last_run, "last_run": config.last_run,
'next_run': config.next_run, "next_run": config.next_run,
'status': config.status "status": config.status,
} }
finally: finally:
session.close() session.close()
@@ -191,9 +202,10 @@ class GarminSyncDaemon:
def start_web_ui(self, port): def start_web_ui(self, port):
"""Start FastAPI web server in a separate thread""" """Start FastAPI web server in a separate thread"""
try: try:
from .web.app import app
import uvicorn import uvicorn
from .web.app import app
def run_server(): def run_server():
try: try:
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info") uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
@@ -230,7 +242,7 @@ class GarminSyncDaemon:
status=status, status=status,
message=message, message=message,
activities_processed=0, # Can be updated later if needed activities_processed=0, # Can be updated later if needed
activities_downloaded=0 # Can be updated later if needed activities_downloaded=0, # Can be updated later if needed
) )
session.add(log) session.add(log)
session.commit() session.commit()

View File

@@ -1,82 +1,161 @@
"""Database module for GarminSync application."""
import os import os
from sqlalchemy import create_engine, Column, Integer, String, Boolean, Float from datetime import datetime
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy import Boolean, Column, Float, Integer, String, create_engine
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base() Base = declarative_base()
class Activity(Base): class Activity(Base):
__tablename__ = 'activities' """Activity model representing a Garmin activity record."""
__tablename__ = "activities"
activity_id = Column(Integer, primary_key=True) activity_id = Column(Integer, primary_key=True)
start_time = Column(String, nullable=False) start_time = Column(String, nullable=False)
activity_type = Column(String, nullable=True) # NEW activity_type = Column(String, nullable=True)
duration = Column(Integer, nullable=True) # NEW (seconds) duration = Column(Integer, nullable=True)
distance = Column(Float, nullable=True) # NEW (meters) distance = Column(Float, nullable=True)
max_heart_rate = Column(Integer, nullable=True) # NEW max_heart_rate = Column(Integer, nullable=True)
avg_power = Column(Float, nullable=True) # NEW avg_power = Column(Float, nullable=True)
calories = Column(Integer, nullable=True) # NEW calories = Column(Integer, nullable=True)
filename = Column(String, unique=True, nullable=True) filename = Column(String, unique=True, nullable=True)
downloaded = Column(Boolean, default=False, nullable=False) downloaded = Column(Boolean, default=False, nullable=False)
created_at = Column(String, nullable=False) created_at = Column(String, nullable=False)
last_sync = Column(String, nullable=True) # ISO timestamp of last sync last_sync = Column(String, nullable=True)
@classmethod
def get_paginated(cls, page=1, per_page=10):
"""Get paginated list of activities.
Args:
page: Page number (1-based)
per_page: Number of items per page
Returns:
Pagination object with activities
"""
session = get_session()
try:
query = session.query(cls).order_by(cls.start_time.desc())
page = int(page)
per_page = int(per_page)
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return pagination
finally:
session.close()
def to_dict(self):
"""Convert activity to dictionary representation.
Returns:
Dictionary with activity data
"""
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_power": self.avg_power,
"calories": self.calories,
}
class DaemonConfig(Base): class DaemonConfig(Base):
__tablename__ = 'daemon_config' """Daemon configuration model."""
__tablename__ = "daemon_config"
id = Column(Integer, primary_key=True, default=1) id = Column(Integer, primary_key=True, default=1)
enabled = Column(Boolean, default=True, nullable=False) enabled = Column(Boolean, default=True, nullable=False)
schedule_cron = Column(String, default="0 */6 * * *", nullable=False) # Every 6 hours schedule_cron = Column(String, default="0 */6 * * *", nullable=False)
last_run = Column(String, nullable=True) last_run = Column(String, nullable=True)
next_run = Column(String, nullable=True) next_run = Column(String, nullable=True)
status = Column(String, default="stopped", nullable=False) # stopped, running, error status = Column(String, default="stopped", nullable=False)
class SyncLog(Base): class SyncLog(Base):
__tablename__ = 'sync_logs' """Sync log model for tracking sync operations."""
__tablename__ = "sync_logs"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
timestamp = Column(String, nullable=False) timestamp = Column(String, nullable=False)
operation = Column(String, nullable=False) # sync, download, daemon_start, daemon_stop operation = Column(String, nullable=False)
status = Column(String, nullable=False) # success, error, partial status = Column(String, nullable=False)
message = Column(String, nullable=True) message = Column(String, nullable=True)
activities_processed = Column(Integer, default=0, nullable=False) activities_processed = Column(Integer, default=0, nullable=False)
activities_downloaded = Column(Integer, default=0, nullable=False) activities_downloaded = Column(Integer, default=0, nullable=False)
def init_db(): def init_db():
"""Initialize database connection and create tables""" """Initialize database connection and create tables.
Returns:
SQLAlchemy engine instance
"""
db_path = os.path.join(os.getenv("DATA_DIR", "data"), "garmin.db") db_path = os.path.join(os.getenv("DATA_DIR", "data"), "garmin.db")
engine = create_engine(f"sqlite:///{db_path}") engine = create_engine(f"sqlite:///{db_path}")
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
return engine return engine
def get_session(): def get_session():
"""Create a new database session""" """Create a new database session.
Returns:
SQLAlchemy session instance
"""
engine = init_db() engine = init_db()
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)
return Session() return Session()
def sync_database(garmin_client): def sync_database(garmin_client):
"""Sync local database with Garmin Connect activities""" """Sync local database with Garmin Connect activities.
from datetime import datetime
Args:
garmin_client: GarminClient instance for API communication
"""
session = get_session() session = get_session()
try: try:
# Fetch activities from Garmin Connect
activities = garmin_client.get_activities(0, 1000) activities = garmin_client.get_activities(0, 1000)
# Process activities and update database if not activities:
for activity in activities: print("No activities returned from Garmin API")
activity_id = activity["activityId"] return
start_time = activity["startTimeLocal"]
# Check if activity exists in database for activity in activities:
existing = session.query(Activity).filter_by(activity_id=activity_id).first() # Check if activity is a dictionary and has required fields
if not isinstance(activity, dict):
print(f"Invalid activity data: {activity}")
continue
# Safely access dictionary keys
activity_id = activity.get("activityId")
start_time = activity.get("startTimeLocal")
if not activity_id or not start_time:
print(f"Missing required fields in activity: {activity}")
continue
existing = (
session.query(Activity).filter_by(activity_id=activity_id).first()
)
if not existing: if not existing:
new_activity = Activity( new_activity = Activity(
activity_id=activity_id, activity_id=activity_id,
start_time=start_time, start_time=start_time,
downloaded=False, downloaded=False,
created_at=datetime.now().isoformat(), # Add this line created_at=datetime.now().isoformat(),
last_sync=datetime.now().isoformat() last_sync=datetime.now().isoformat(),
) )
session.add(new_activity) session.add(new_activity)
@@ -87,25 +166,24 @@ def sync_database(garmin_client):
finally: finally:
session.close() session.close()
def get_offline_stats(): def get_offline_stats():
"""Return statistics about cached data without API calls""" """Return statistics about cached data without API calls.
Returns:
Dictionary with activity statistics
"""
session = get_session() session = get_session()
try: try:
total = session.query(Activity).count() total = session.query(Activity).count()
downloaded = session.query(Activity).filter_by(downloaded=True).count() downloaded = session.query(Activity).filter_by(downloaded=True).count()
missing = total - downloaded missing = total - downloaded
# Get most recent sync timestamp
last_sync = session.query(Activity).order_by(Activity.last_sync.desc()).first() last_sync = session.query(Activity).order_by(Activity.last_sync.desc()).first()
return { return {
'total': total, "total": total,
'downloaded': downloaded, "downloaded": downloaded,
'missing': missing, "missing": missing,
'last_sync': last_sync.last_sync if last_sync else 'Never synced' "last_sync": last_sync.last_sync if last_sync else "Never synced",
} }
finally: finally:
session.close() session.close()
# Example usage:
# from .garmin import GarminClient
# client = GarminClient()
# sync_database(client)

View File

@@ -1,8 +1,19 @@
"""Garmin API client module for GarminSync application."""
import logging
import os import os
import time import time
from garminconnect import Garmin
from garminconnect import (Garmin, GarminConnectAuthenticationError,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError)
logger = logging.getLogger(__name__)
class GarminClient: class GarminClient:
"""Garmin API client for interacting with Garmin Connect services."""
def __init__(self): def __init__(self):
self.client = None self.client = None
@@ -14,18 +25,50 @@ class GarminClient:
if not email or not password: if not email or not password:
raise ValueError("Garmin credentials not found in environment variables") raise ValueError("Garmin credentials not found in environment variables")
try:
self.client = Garmin(email, password) self.client = Garmin(email, password)
self.client.login() self.client.login()
logger.info("Successfully authenticated with Garmin Connect")
return self.client return self.client
except GarminConnectAuthenticationError as e:
logger.error("Authentication failed: %s", e)
raise ValueError(f"Garmin authentication failed: {e}") from e
except GarminConnectConnectionError as e:
logger.error("Connection error: %s", e)
raise ConnectionError(f"Failed to connect to Garmin Connect: {e}") from e
except Exception as e:
logger.error("Unexpected error during authentication: %s", e)
raise RuntimeError(f"Unexpected error during authentication: {e}") from e
def get_activities(self, start=0, limit=10): def get_activities(self, start=0, limit=10):
"""Get list of activities with rate limiting""" """Get list of activities with rate limiting
Args:
start: Starting index for activities
limit: Maximum number of activities to return
Returns:
List of activities or None if failed
Raises:
ValueError: If authentication fails
ConnectionError: If connection to Garmin fails
RuntimeError: For other unexpected errors
"""
if not self.client: if not self.client:
self.authenticate() self.authenticate()
try:
activities = self.client.get_activities(start, limit) activities = self.client.get_activities(start, limit)
time.sleep(2) # Rate limiting time.sleep(2) # Rate limiting
logger.info("Retrieved %d activities", len(activities) if activities else 0)
return activities return activities
except (GarminConnectConnectionError, TimeoutError, GarminConnectTooManyRequestsError) as e:
logger.error("Network error while fetching activities: %s", e)
raise ConnectionError(f"Failed to fetch activities: {e}") from e
except Exception as e: # pylint: disable=broad-except
logger.error("Unexpected error while fetching activities: %s", e)
raise RuntimeError(f"Failed to fetch activities: {e}") from e
def download_activity_fit(self, activity_id): def download_activity_fit(self, activity_id):
"""Download .fit file for a specific activity""" """Download .fit file for a specific activity"""
@@ -38,54 +81,84 @@ class GarminClient:
methods_to_try = [ methods_to_try = [
# Method 1: No format parameter (most likely to work) # Method 1: No format parameter (most likely to work)
lambda: self.client.download_activity(activity_id), lambda: self.client.download_activity(activity_id),
# Method 2: Use correct parameter name with different values
# Method 2: Use 'fmt' instead of 'dl_fmt' lambda: self.client.download_activity(activity_id, dl_fmt="FIT"),
lambda: self.client.download_activity(activity_id, fmt='fit'), lambda: self.client.download_activity(
activity_id, dl_fmt="tcx"
# Method 3: Use 'format' parameter ), # Fallback format
lambda: self.client.download_activity(activity_id, format='fit'),
# Method 4: Try original parameter name with different values
lambda: self.client.download_activity(activity_id, dl_fmt='FIT'),
lambda: self.client.download_activity(activity_id, dl_fmt='tcx'), # Fallback format
] ]
last_exception = None last_exception = None
for i, method in enumerate(methods_to_try, 1): for i, method in enumerate(methods_to_try, 1):
try: try:
# Try the download method
print(f"Trying download method {i}...") print(f"Trying download method {i}...")
fit_data = method() fit_data = method()
if fit_data: if fit_data:
print(f"Successfully downloaded {len(fit_data)} bytes using method {i}") print(
f"Successfully downloaded {len(fit_data)} bytes using method {i}"
)
time.sleep(2) # Rate limiting time.sleep(2) # Rate limiting
return fit_data return fit_data
else:
print(f"Method {i} returned empty data") print(f"Method {i} returned empty data")
except Exception as e: # Catch connection errors specifically
print(f"Method {i} failed: {type(e).__name__}: {e}") except (GarminConnectConnectionError, ConnectionError) as e: # pylint: disable=duplicate-except
print(f"Method {i} failed with connection error: {e}")
last_exception = e
continue
# Catch all other exceptions as a fallback
except (TimeoutError, GarminConnectTooManyRequestsError) as e:
print(f"Method {i} failed with retryable error: {e}")
last_exception = e
continue
except Exception as e: # pylint: disable=broad-except
print(f"Method {i} failed with unexpected error: "
f"{type(e).__name__}: {e}")
last_exception = e last_exception = e
continue continue
# If all methods failed, raise the last exception # If all methods failed, raise the last exception
raise RuntimeError(f"All download methods failed. Last error: {last_exception}") if last_exception:
raise RuntimeError(
f"All download methods failed. Last error: {last_exception}"
) from last_exception
raise RuntimeError(
"All download methods failed, but no specific error was captured"
)
def get_activity_details(self, activity_id): def get_activity_details(self, activity_id):
"""Get detailed information about a specific activity""" """Get detailed information about a specific activity
Args:
activity_id: ID of the activity to retrieve
Returns:
Activity details dictionary or None if failed
"""
if not self.client: if not self.client:
self.authenticate() self.authenticate()
try: try:
activity_details = self.client.get_activity_by_id(activity_id) activity_details = self.client.get_activity(activity_id)
time.sleep(2) # Rate limiting time.sleep(2) # Rate limiting
logger.info("Retrieved details for activity %s", activity_id)
return activity_details return activity_details
except Exception as e: except (GarminConnectConnectionError, TimeoutError) as e:
print(f"Failed to get activity details for {activity_id}: {e}") logger.error(
"Connection/timeout error fetching activity details for %s: %s",
activity_id, e
)
return None
except Exception as e: # pylint: disable=broad-except
logger.error("Unexpected error fetching activity details for %s: %s", activity_id, e)
return None return None
# Example usage and testing function # Example usage and testing function
def test_download(activity_id): def test_download(activity_id):
"""Test function to verify download functionality""" """Test function to verify download functionality"""
client = GarminClient() client = GarminClient()
@@ -93,27 +166,27 @@ def test_download(activity_id):
fit_data = client.download_activity_fit(activity_id) fit_data = client.download_activity_fit(activity_id)
# Verify the data looks like a FIT file # Verify the data looks like a FIT file
if fit_data and len(fit_data) > 14: if not fit_data or len(fit_data) <= 14:
# FIT files start with specific header print("❌ Downloaded data is empty or too small")
return None
header = fit_data[:14] header = fit_data[:14]
if b'.FIT' in header or header[8:12] == b'.FIT': if b".FIT" in header or header[8:12] == b".FIT":
print("✅ Downloaded data appears to be a valid FIT file") print("✅ Downloaded data appears to be a valid FIT file")
return fit_data
else: else:
print("⚠️ Downloaded data may not be a FIT file") print("⚠️ Downloaded data may not be a FIT file")
print(f"Header: {header}") print(f"Header: {header}")
return fit_data return fit_data
else:
print("❌ Downloaded data is empty or too small")
return None
except Exception as e: except Exception as e: # pylint: disable=broad-except
print(f"❌ Test failed: {e}") print(f"❌ Test failed: {e}")
return None return None
if __name__ == "__main__": if __name__ == "__main__":
# Test with a sample activity ID if provided # Test with a sample activity ID if provided
import sys import sys
if len(sys.argv) > 1: if len(sys.argv) > 1:
test_activity_id = sys.argv[1] test_activity_id = sys.argv[1]
print(f"Testing download for activity ID: {test_activity_id}") print(f"Testing download for activity ID: {test_activity_id}")

View File

@@ -6,9 +6,10 @@ Migration script to populate new activity fields from Garmin API
import os import os
import sys import sys
from datetime import datetime from datetime import datetime
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine, MetaData, Table, text from sqlalchemy import MetaData, Table, create_engine, text
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import sessionmaker
# Add the parent directory to the path to import garminsync modules # Add the parent directory to the path to import garminsync modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -31,23 +32,42 @@ def add_columns_to_database():
metadata.reflect(bind=engine) metadata.reflect(bind=engine)
# Get the activities table # Get the activities table
activities_table = metadata.tables['activities'] activities_table = metadata.tables["activities"]
# Check if columns already exist # Check if columns already exist
existing_columns = [col.name for col in activities_table.columns] existing_columns = [col.name for col in activities_table.columns]
new_columns = ['activity_type', 'duration', 'distance', 'max_heart_rate', 'avg_power', 'calories'] new_columns = [
"activity_type",
"duration",
"distance",
"max_heart_rate",
"avg_power",
"calories",
]
# Add missing columns # Add missing columns
with engine.connect() as conn: with engine.connect() as conn:
for column_name in new_columns: for column_name in new_columns:
if column_name not in existing_columns: if column_name not in existing_columns:
print(f"Adding column {column_name}...") print(f"Adding column {column_name}...")
if column_name in ['distance', 'avg_power']: if column_name in ["distance", "avg_power"]:
conn.execute(text(f"ALTER TABLE activities ADD COLUMN {column_name} REAL")) conn.execute(
elif column_name in ['duration', 'max_heart_rate', 'calories']: text(
conn.execute(text(f"ALTER TABLE activities ADD COLUMN {column_name} INTEGER")) f"ALTER TABLE activities ADD COLUMN {column_name} REAL"
)
)
elif column_name in ["duration", "max_heart_rate", "calories"]:
conn.execute(
text(
f"ALTER TABLE activities ADD COLUMN {column_name} INTEGER"
)
)
else: else:
conn.execute(text(f"ALTER TABLE activities ADD COLUMN {column_name} TEXT")) conn.execute(
text(
f"ALTER TABLE activities ADD COLUMN {column_name} TEXT"
)
)
conn.commit() conn.commit()
print(f"Column {column_name} added successfully") print(f"Column {column_name} added successfully")
else: else:
@@ -83,7 +103,9 @@ def migrate_activities():
try: try:
# Get all activities that need to be updated (those with NULL activity_type) # Get all activities that need to be updated (those with NULL activity_type)
activities = session.query(Activity).filter(Activity.activity_type.is_(None)).all() activities = (
session.query(Activity).filter(Activity.activity_type.is_(None)).all()
)
print(f"Found {len(activities)} activities to migrate") print(f"Found {len(activities)} activities to migrate")
# If no activities found, try to get all activities (in case activity_type column was just added) # If no activities found, try to get all activities (in case activity_type column was just added)
@@ -96,7 +118,9 @@ def migrate_activities():
for i, activity in enumerate(activities): for i, activity in enumerate(activities):
try: try:
print(f"Processing activity {i+1}/{len(activities)} (ID: {activity.activity_id})") print(
f"Processing activity {i+1}/{len(activities)} (ID: {activity.activity_id})"
)
# Fetch detailed activity data from Garmin (if client is available) # Fetch detailed activity data from Garmin (if client is available)
activity_details = None activity_details = None
@@ -106,30 +130,32 @@ def migrate_activities():
# Update activity fields if we have details # Update activity fields if we have details
if activity_details: if activity_details:
# Update activity fields # Update activity fields
activity.activity_type = activity_details.get('activityType', {}).get('typeKey') activity.activity_type = activity_details.get(
"activityType", {}
).get("typeKey")
# Extract duration in seconds # Extract duration in seconds
duration = activity_details.get('summaryDTO', {}).get('duration') duration = activity_details.get("summaryDTO", {}).get("duration")
if duration is not None: if duration is not None:
activity.duration = int(float(duration)) activity.duration = int(float(duration))
# Extract distance in meters # Extract distance in meters
distance = activity_details.get('summaryDTO', {}).get('distance') distance = activity_details.get("summaryDTO", {}).get("distance")
if distance is not None: if distance is not None:
activity.distance = float(distance) activity.distance = float(distance)
# Extract max heart rate # Extract max heart rate
max_hr = activity_details.get('summaryDTO', {}).get('maxHR') max_hr = activity_details.get("summaryDTO", {}).get("maxHR")
if max_hr is not None: if max_hr is not None:
activity.max_heart_rate = int(float(max_hr)) activity.max_heart_rate = int(float(max_hr))
# Extract average power # Extract average power
avg_power = activity_details.get('summaryDTO', {}).get('avgPower') avg_power = activity_details.get("summaryDTO", {}).get("avgPower")
if avg_power is not None: if avg_power is not None:
activity.avg_power = float(avg_power) activity.avg_power = float(avg_power)
# Extract calories # Extract calories
calories = activity_details.get('summaryDTO', {}).get('calories') calories = activity_details.get("summaryDTO", {}).get("calories")
if calories is not None: if calories is not None:
activity.calories = int(float(calories)) activity.calories = int(float(calories))
else: else:

View File

@@ -2,6 +2,7 @@ import logging
import sys import sys
from datetime import datetime from datetime import datetime
# Configure logging # Configure logging
def setup_logger(name="garminsync", level=logging.INFO): def setup_logger(name="garminsync", level=logging.INFO):
"""Setup logger with consistent formatting""" """Setup logger with consistent formatting"""
@@ -19,7 +20,7 @@ def setup_logger(name="garminsync", level=logging.INFO):
# Create formatter # Create formatter
formatter = logging.Formatter( formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s' "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
) )
handler.setFormatter(formatter) handler.setFormatter(formatter)
@@ -28,9 +29,11 @@ def setup_logger(name="garminsync", level=logging.INFO):
return logger return logger
# Create default logger instance # Create default logger instance
logger = setup_logger() logger = setup_logger()
def format_timestamp(timestamp_str=None): def format_timestamp(timestamp_str=None):
"""Format timestamp string for display""" """Format timestamp string for display"""
if not timestamp_str: if not timestamp_str:
@@ -38,48 +41,56 @@ def format_timestamp(timestamp_str=None):
try: try:
# Parse ISO format timestamp # Parse ISO format timestamp
dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S") return dt.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, AttributeError): except (ValueError, AttributeError):
return timestamp_str return timestamp_str
def safe_filename(filename): def safe_filename(filename):
"""Make filename safe for filesystem""" """Make filename safe for filesystem"""
import re import re
# Replace problematic characters # Replace problematic characters
safe_name = re.sub(r'[<>:"/\\|?*]', '_', filename) safe_name = re.sub(r'[<>:"/\\|?*]', "_", filename)
# Replace spaces and colons commonly found in timestamps # Replace spaces and colons commonly found in timestamps
safe_name = safe_name.replace(':', '-').replace(' ', '_') safe_name = safe_name.replace(":", "-").replace(" ", "_")
return safe_name return safe_name
def bytes_to_human_readable(bytes_count): def bytes_to_human_readable(bytes_count):
"""Convert bytes to human readable format""" """Convert bytes to human readable format"""
if bytes_count == 0: if bytes_count == 0:
return "0 B" return "0 B"
for unit in ['B', 'KB', 'MB', 'GB']: for unit in ["B", "KB", "MB", "GB"]:
if bytes_count < 1024.0: if bytes_count < 1024.0:
return f"{bytes_count:.1f} {unit}" return f"{bytes_count:.1f} {unit}"
bytes_count /= 1024.0 bytes_count /= 1024.0
return f"{bytes_count:.1f} TB" return f"{bytes_count:.1f} TB"
def validate_cron_expression(cron_expr): def validate_cron_expression(cron_expr):
"""Basic validation of cron expression""" """Basic validation of cron expression"""
try: try:
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
# Try to create a CronTrigger with the expression # Try to create a CronTrigger with the expression
CronTrigger.from_crontab(cron_expr) CronTrigger.from_crontab(cron_expr)
return True return True
except (ValueError, TypeError): except (ValueError, TypeError):
return False return False
# Utility function for error handling # Utility function for error handling
def handle_db_error(func): def handle_db_error(func):
"""Decorator for database operations with error handling""" """Decorator for database operations with error handling"""
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except Exception as e: except Exception as e:
logger.error(f"Database operation failed in {func.__name__}: {e}") logger.error(f"Database operation failed in {func.__name__}: {e}")
raise raise
return wrapper return wrapper

View File

@@ -1,9 +1,11 @@
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import JSONResponse
import os import os
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from .routes import router from .routes import router
app = FastAPI(title="GarminSync Dashboard") app = FastAPI(title="GarminSync Dashboard")
@@ -26,51 +28,60 @@ else:
# Include API routes # Include API routes
app.include_router(router) app.include_router(router)
@app.get("/") @app.get("/")
async def dashboard(request: Request): async def dashboard(request: Request):
"""Dashboard route with fallback for missing templates""" """Dashboard route with fallback for missing templates"""
if not templates: if not templates:
# Return JSON response if templates are not available # Return JSON response if templates are not available
from garminsync.database import get_offline_stats from garminsync.database import get_offline_stats
stats = get_offline_stats() stats = get_offline_stats()
return JSONResponse({ return JSONResponse(
{
"message": "GarminSync Dashboard", "message": "GarminSync Dashboard",
"stats": stats, "stats": stats,
"note": "Web UI templates not found, showing JSON response" "note": "Web UI templates not found, showing JSON response",
}) }
)
try: try:
# Get current statistics # Get current statistics
from garminsync.database import get_offline_stats from garminsync.database import get_offline_stats
stats = get_offline_stats() stats = get_offline_stats()
return templates.TemplateResponse("dashboard.html", { return templates.TemplateResponse(
"request": request, "dashboard.html", {"request": request, "stats": stats}
"stats": stats )
})
except Exception as e: except Exception as e:
return JSONResponse({ return JSONResponse(
{
"error": f"Failed to load dashboard: {str(e)}", "error": f"Failed to load dashboard: {str(e)}",
"message": "Dashboard unavailable, API endpoints still functional" "message": "Dashboard unavailable, API endpoints still functional",
}) }
)
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
"""Health check endpoint""" """Health check endpoint"""
return {"status": "healthy", "service": "GarminSync Dashboard"} return {"status": "healthy", "service": "GarminSync Dashboard"}
@app.get("/config") @app.get("/config")
async def config_page(request: Request): async def config_page(request: Request):
"""Configuration page""" """Configuration page"""
if not templates: if not templates:
return JSONResponse({ return JSONResponse(
{
"message": "Configuration endpoint", "message": "Configuration endpoint",
"note": "Use /api/schedule endpoints for configuration" "note": "Use /api/schedule endpoints for configuration",
}) }
)
return templates.TemplateResponse("config.html", {"request": request})
return templates.TemplateResponse("config.html", {
"request": request
})
@app.get("/activities") @app.get("/activities")
async def activities_page(request: Request): async def activities_page(request: Request):
@@ -78,21 +89,19 @@ async def activities_page(request: Request):
if not templates: if not templates:
return JSONResponse({"message": "Activities endpoint"}) return JSONResponse({"message": "Activities endpoint"})
return templates.TemplateResponse("activities.html", { return templates.TemplateResponse("activities.html", {"request": request})
"request": request
})
# Error handlers # Error handlers
@app.exception_handler(404) @app.exception_handler(404)
async def not_found_handler(request: Request, exc): async def not_found_handler(request: Request, exc):
return JSONResponse( return JSONResponse(
status_code=404, status_code=404, content={"error": "Not found", "path": str(request.url.path)}
content={"error": "Not found", "path": str(request.url.path)}
) )
@app.exception_handler(500) @app.exception_handler(500)
async def server_error_handler(request: Request, exc): async def server_error_handler(request: Request, exc):
return JSONResponse( return JSONResponse(
status_code=500, status_code=500, content={"error": "Internal server error", "detail": str(exc)}
content={"error": "Internal server error", "detail": str(exc)}
) )

View File

@@ -1,14 +1,18 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from garminsync.database import get_session, DaemonConfig, SyncLog, Activity
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from garminsync.database import Activity, DaemonConfig, SyncLog, get_session
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
class ScheduleConfig(BaseModel): class ScheduleConfig(BaseModel):
enabled: bool enabled: bool
cron_schedule: str cron_schedule: str
@router.get("/status") @router.get("/status")
async def get_status(): async def get_status():
"""Get current daemon status""" """Get current daemon status"""
@@ -25,27 +29,27 @@ async def get_status():
"next_run": config.next_run if config else None, "next_run": config.next_run if config else None,
"schedule": config.schedule_cron if config else None, "schedule": config.schedule_cron if config else None,
"last_run": config.last_run if config else None, "last_run": config.last_run if config else None,
"enabled": config.enabled if config else False "enabled": config.enabled if config else False,
} }
log_data = [] log_data = []
for log in logs: for log in logs:
log_data.append({ log_data.append(
{
"timestamp": log.timestamp, "timestamp": log.timestamp,
"operation": log.operation, "operation": log.operation,
"status": log.status, "status": log.status,
"message": log.message, "message": log.message,
"activities_processed": log.activities_processed, "activities_processed": log.activities_processed,
"activities_downloaded": log.activities_downloaded "activities_downloaded": log.activities_downloaded,
})
return {
"daemon": daemon_data,
"recent_logs": log_data
} }
)
return {"daemon": daemon_data, "recent_logs": log_data}
finally: finally:
session.close() session.close()
@router.post("/schedule") @router.post("/schedule")
async def update_schedule(config: ScheduleConfig): async def update_schedule(config: ScheduleConfig):
"""Update daemon schedule configuration""" """Update daemon schedule configuration"""
@@ -64,21 +68,25 @@ async def update_schedule(config: ScheduleConfig):
return {"message": "Configuration updated successfully"} return {"message": "Configuration updated successfully"}
except Exception as e: except Exception as e:
session.rollback() session.rollback()
raise HTTPException(status_code=500, detail=f"Failed to update configuration: {str(e)}") raise HTTPException(
status_code=500, detail=f"Failed to update configuration: {str(e)}"
)
finally: finally:
session.close() session.close()
@router.post("/sync/trigger") @router.post("/sync/trigger")
async def trigger_sync(): async def trigger_sync():
"""Manually trigger a sync operation""" """Manually trigger a sync operation"""
try: try:
# Import here to avoid circular imports # Import here to avoid circular imports
from garminsync.garmin import GarminClient
from garminsync.database import sync_database, Activity
from datetime import datetime
import os import os
from datetime import datetime
from pathlib import Path from pathlib import Path
from garminsync.database import Activity, sync_database
from garminsync.garmin import GarminClient
# Create client and sync # Create client and sync
client = GarminClient() client = GarminClient()
sync_database(client) sync_database(client)
@@ -86,7 +94,9 @@ async def trigger_sync():
# Download missing activities # Download missing activities
session = get_session() session = get_session()
try: try:
missing_activities = session.query(Activity).filter_by(downloaded=False).all() missing_activities = (
session.query(Activity).filter_by(downloaded=False).all()
)
downloaded_count = 0 downloaded_count = 0
data_dir = Path(os.getenv("DATA_DIR", "data")) data_dir = Path(os.getenv("DATA_DIR", "data"))
@@ -113,26 +123,31 @@ async def trigger_sync():
print(f"Failed to download activity {activity.activity_id}: {e}") print(f"Failed to download activity {activity.activity_id}: {e}")
session.rollback() session.rollback()
return {"message": f"Sync completed successfully. Downloaded {downloaded_count} activities."} return {
"message": f"Sync completed successfully. Downloaded {downloaded_count} activities."
}
finally: finally:
session.close() session.close()
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
@router.get("/activities/stats") @router.get("/activities/stats")
async def get_activity_stats(): async def get_activity_stats():
"""Get activity statistics""" """Get activity statistics"""
from garminsync.database import get_offline_stats from garminsync.database import get_offline_stats
return get_offline_stats() return get_offline_stats()
@router.get("/logs") @router.get("/logs")
async def get_logs( async def get_logs(
status: str = None, status: str = None,
operation: str = None, operation: str = None,
date: str = None, date: str = None,
page: int = 1, page: int = 1,
per_page: int = 20 per_page: int = 20,
): ):
"""Get sync logs with filtering and pagination""" """Get sync logs with filtering and pagination"""
session = get_session() session = get_session()
@@ -152,39 +167,41 @@ async def get_logs(
total = query.count() total = query.count()
# Apply pagination # Apply pagination
logs = query.order_by(SyncLog.timestamp.desc()) \ logs = (
.offset((page - 1) * per_page) \ query.order_by(SyncLog.timestamp.desc())
.limit(per_page) \ .offset((page - 1) * per_page)
.limit(per_page)
.all() .all()
)
log_data = [] log_data = []
for log in logs: for log in logs:
log_data.append({ log_data.append(
{
"id": log.id, "id": log.id,
"timestamp": log.timestamp, "timestamp": log.timestamp,
"operation": log.operation, "operation": log.operation,
"status": log.status, "status": log.status,
"message": log.message, "message": log.message,
"activities_processed": log.activities_processed, "activities_processed": log.activities_processed,
"activities_downloaded": log.activities_downloaded "activities_downloaded": log.activities_downloaded,
})
return {
"logs": log_data,
"total": total,
"page": page,
"per_page": per_page
} }
)
return {"logs": log_data, "total": total, "page": page, "per_page": per_page}
finally: finally:
session.close() session.close()
@router.post("/daemon/start") @router.post("/daemon/start")
async def start_daemon(): async def start_daemon():
"""Start the daemon process""" """Start the daemon process"""
from garminsync.daemon import daemon_instance from garminsync.daemon import daemon_instance
try: try:
# Start the daemon in a separate thread to avoid blocking # Start the daemon in a separate thread to avoid blocking
import threading import threading
daemon_thread = threading.Thread(target=daemon_instance.start) daemon_thread = threading.Thread(target=daemon_instance.start)
daemon_thread.daemon = True daemon_thread.daemon = True
daemon_thread.start() daemon_thread.start()
@@ -205,10 +222,12 @@ async def start_daemon():
finally: finally:
session.close() session.close()
@router.post("/daemon/stop") @router.post("/daemon/stop")
async def stop_daemon(): async def stop_daemon():
"""Stop the daemon process""" """Stop the daemon process"""
from garminsync.daemon import daemon_instance from garminsync.daemon import daemon_instance
try: try:
# Stop the daemon # Stop the daemon
daemon_instance.stop() daemon_instance.stop()
@@ -227,6 +246,7 @@ async def stop_daemon():
finally: finally:
session.close() session.close()
@router.delete("/logs") @router.delete("/logs")
async def clear_logs(): async def clear_logs():
"""Clear all sync logs""" """Clear all sync logs"""
@@ -241,13 +261,14 @@ async def clear_logs():
finally: finally:
session.close() session.close()
@router.get("/activities") @router.get("/activities")
async def get_activities( async def get_activities(
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
activity_type: str = None, activity_type: str = None,
date_from: str = None, date_from: str = None,
date_to: str = None date_to: str = None,
): ):
"""Get paginated activities with filtering""" """Get paginated activities with filtering"""
session = get_session() session = get_session()
@@ -266,14 +287,17 @@ async def get_activities(
total = query.count() total = query.count()
# Apply pagination # Apply pagination
activities = query.order_by(Activity.start_time.desc()) \ activities = (
.offset((page - 1) * per_page) \ query.order_by(Activity.start_time.desc())
.limit(per_page) \ .offset((page - 1) * per_page)
.limit(per_page)
.all() .all()
)
activity_data = [] activity_data = []
for activity in activities: for activity in activities:
activity_data.append({ activity_data.append(
{
"activity_id": activity.activity_id, "activity_id": activity.activity_id,
"start_time": activity.start_time, "start_time": activity.start_time,
"activity_type": activity.activity_type, "activity_type": activity.activity_type,
@@ -285,46 +309,120 @@ async def get_activities(
"filename": activity.filename, "filename": activity.filename,
"downloaded": activity.downloaded, "downloaded": activity.downloaded,
"created_at": activity.created_at, "created_at": activity.created_at,
"last_sync": activity.last_sync "last_sync": activity.last_sync,
}) }
)
return { return {
"activities": activity_data, "activities": activity_data,
"total": total, "total": total,
"page": page, "page": page,
"per_page": per_page "per_page": per_page,
} }
finally: finally:
session.close() session.close()
@router.get("/activities/{activity_id}") @router.get("/activities/{activity_id}")
async def get_activity_details(activity_id: int): async def get_activity_details(activity_id: int):
"""Get detailed activity information""" """Get detailed activity information"""
session = get_session() session = get_session()
try: try:
activity = session.query(Activity).filter(Activity.activity_id == activity_id).first() activity = (
session.query(Activity).filter(Activity.activity_id == activity_id).first()
)
if not activity: if not activity:
raise HTTPException(status_code=404, detail="Activity not found") raise HTTPException(
status_code=404, detail=f"Activity with ID {activity_id} not found"
)
return { return {
"activity_id": activity.activity_id, "id": activity.activity_id,
"name": activity.filename or "Unnamed Activity",
"distance": activity.distance,
"duration": activity.duration,
"start_time": activity.start_time, "start_time": activity.start_time,
"activity_type": activity.activity_type, "activity_type": activity.activity_type,
"duration": activity.duration,
"distance": activity.distance,
"max_heart_rate": activity.max_heart_rate, "max_heart_rate": activity.max_heart_rate,
"avg_power": activity.avg_power, "avg_power": activity.avg_power,
"calories": activity.calories, "calories": activity.calories,
"filename": activity.filename, "filename": activity.filename,
"downloaded": activity.downloaded, "downloaded": activity.downloaded,
"created_at": activity.created_at, "created_at": activity.created_at,
"last_sync": activity.last_sync "last_sync": activity.last_sync,
} }
finally: finally:
session.close() session.close()
@router.get("/dashboard/stats") @router.get("/dashboard/stats")
async def get_dashboard_stats(): async def get_dashboard_stats():
"""Get comprehensive dashboard statistics""" """Get comprehensive dashboard statistics"""
from garminsync.database import get_offline_stats from garminsync.database import get_offline_stats
return get_offline_stats() return get_offline_stats()
@router.get("/api/activities")
async def get_api_activities(page: int = 1, per_page: int = 10):
"""Get paginated activities for API"""
session = get_session()
try:
# Use the existing get_paginated method from Activity class
pagination = Activity.get_paginated(page, per_page)
activities = pagination.items
total_pages = pagination.pages
current_page = pagination.page
total_items = pagination.total
if not activities and page > 1:
raise HTTPException(
status_code=404, detail=f"No activities found for page {page}"
)
if not activities and page == 1 and total_items == 0:
raise HTTPException(status_code=404, detail="No activities found")
if not activities:
raise HTTPException(status_code=404, detail="No activities found")
return {
"activities": [
{
"id": activity.activity_id,
"name": activity.filename or "Unnamed Activity",
"distance": activity.distance,
"duration": activity.duration,
"start_time": activity.start_time,
"activity_type": activity.activity_type,
"max_heart_rate": activity.max_heart_rate,
"avg_power": activity.avg_power,
"calories": activity.calories,
"downloaded": activity.downloaded,
"created_at": activity.created_at,
"last_sync": activity.last_sync,
"device": activity.device or "Unknown",
"intensity": activity.intensity or "Unknown",
"average_speed": activity.average_speed,
"elevation_gain": activity.elevation_gain,
"heart_rate_zones": activity.heart_rate_zones or [],
"power_zones": activity.power_zones or [],
"training_effect": activity.training_effect or 0,
"training_effect_label": activity.training_effect_label
or "Unknown",
}
for activity in activities
],
"total_pages": total_pages,
"current_page": current_page,
"total_items": total_items,
"page_size": per_page,
"status": "success",
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"An error occurred while fetching activities: {str(e)}",
)
finally:
session.close()

View File

@@ -3,14 +3,16 @@
Simple test script to verify the new UI is working correctly Simple test script to verify the new UI is working correctly
""" """
import requests
import time
import sys import sys
import time
from pathlib import Path from pathlib import Path
import requests
# Add the parent directory to the path to import garminsync modules # Add the parent directory to the path to import garminsync modules
sys.path.insert(0, str(Path(__file__).parent.parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent.parent))
def test_ui_endpoints(): def test_ui_endpoints():
"""Test that the new UI endpoints are working correctly""" """Test that the new UI endpoints are working correctly"""
base_url = "http://localhost:8000" base_url = "http://localhost:8000"
@@ -23,7 +25,7 @@ def test_ui_endpoints():
"/logs", "/logs",
"/api/status", "/api/status",
"/api/activities/stats", "/api/activities/stats",
"/api/dashboard/stats" "/api/dashboard/stats",
] ]
print("Testing UI endpoints...") print("Testing UI endpoints...")
@@ -60,6 +62,7 @@ def test_ui_endpoints():
print("\nAll endpoints are working correctly!") print("\nAll endpoints are working correctly!")
return True return True
def test_api_endpoints(): def test_api_endpoints():
"""Test that the new API endpoints are working correctly""" """Test that the new API endpoints are working correctly"""
base_url = "http://localhost:8000" base_url = "http://localhost:8000"
@@ -67,8 +70,11 @@ def test_api_endpoints():
# Test API endpoints # Test API endpoints
api_endpoints = [ api_endpoints = [
("/api/activities", "GET"), ("/api/activities", "GET"),
("/api/activities/1", "GET"), # This might fail if activity doesn't exist, which is OK (
("/api/dashboard/stats", "GET") "/api/activities/1",
"GET",
), # This might fail if activity doesn't exist, which is OK
("/api/dashboard/stats", "GET"),
] ]
print("\nTesting API endpoints...") print("\nTesting API endpoints...")
@@ -93,7 +99,9 @@ def test_api_endpoints():
# Try to parse JSON # Try to parse JSON
try: try:
data = response.json() data = response.json()
print(f" Response keys: {list(data.keys()) if isinstance(data, dict) else 'Not a dict'}") print(
f" Response keys: {list(data.keys()) if isinstance(data, dict) else 'Not a dict'}"
)
except: except:
print(" Response is not JSON") print(" Response is not JSON")
else: else:
@@ -106,6 +114,7 @@ def test_api_endpoints():
except Exception as e: except Exception as e:
print(f"{endpoint} - Error: {e}") print(f"{endpoint} - Error: {e}")
if __name__ == "__main__": if __name__ == "__main__":
print("GarminSync UI Test Script") print("GarminSync UI Test Script")
print("=" * 30) print("=" * 30)

View File

@@ -1,14 +1,58 @@
# GarminSync project tasks
# Build container image
build: build:
docker build -t garminsync . docker build -t garminsync .
# Run server in development mode with live reload (container-based)
dev:
just build
docker run -it --rm --env-file .env -v $(pwd)/garminsync:/app/garminsync -v $(pwd)/data:/app/data -p 8888:8888 --name garminsync-dev garminsync uvicorn garminsync.web.app:app --reload --host 0.0.0.0 --port 8080
# Run database migrations (container-based)
migrate:
just build
docker run --rm --env-file .env -v $(pwd)/data:/app/data --entrypoint "alembic" garminsync upgrade head
# Run validation tests (container-based)
test:
just build
docker run --rm --env-file .env -v $(pwd)/tests:/app/tests -v $(pwd)/data:/app/data --entrypoint "pytest" garminsync /app/tests
# View logs of running container
logs:
docker logs garminsync
# Access container shell
shell:
docker exec -it garminsync /bin/bash
# Run linter (container-based)
lint:
just build
docker run --rm -v $(pwd)/garminsync:/app/garminsync --entrypoint "pylint" garminsync garminsync/
# Run formatter (container-based)
format:
black garminsync/
isort garminsync/
just build
# Start production server
run_server: run_server:
just build just build
docker run -d --rm --env-file .env -v $(pwd)/data:/app/data -p 8888:8080 --name garminsync garminsync daemon --start docker run -d --rm --env-file .env -v $(pwd)/data:/app/data -p 8888:8888 --name garminsync garminsync daemon --start
run_server_live:
just build
docker run --rm --env-file .env -v $(pwd)/data:/app/data -p 8888:8080 --name garminsync garminsync daemon --start
# Stop production server
stop_server: stop_server:
docker stop garminsync docker stop garminsync
# Run server in live mode for debugging
run_server_live:
just build
docker run -it --rm --env-file .env -v $(pwd)/data:/app/data -p 8888:8888 --name garminsync garminsync daemon --start
# Clean up any existing container
cleanup:
docker stop garminsync
docker rm garminsync docker rm garminsync

View File

@@ -1,13 +1,19 @@
typer==0.9.0 flask==3.0.0
click==8.1.7 flask-sqlalchemy==3.1.1
flask-migrate==4.0.7
python-dotenv==1.0.0 python-dotenv==1.0.0
garminconnect==0.2.28 uvicorn==0.27.0
sqlalchemy==2.0.23 alembic==1.13.1
tqdm==4.66.1 flask-paginate==2024.4.12
fastapi==0.104.1 pytest==8.1.1
uvicorn[standard]==0.24.0 typer==0.9.0
apscheduler==3.10.4 apscheduler==3.10.4
pydantic>=2.0.0,<2.5.0 requests==2.32.0
jinja2==3.1.2 garminconnect==0.2.28
python-multipart==0.0.6 garth
aiofiles==23.2.1 fastapi==0.109.1
pydantic==2.5.3
tqdm==4.66.1
sqlalchemy==2.0.30
pylint==3.1.0
pygments==2.18.0