before claude fix #1

This commit is contained in:
2025-12-23 06:09:34 -08:00
parent c505fb69a6
commit a23fa1b30d
83 changed files with 5682 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,178 @@
# Fitbit-Garmin Local Sync
A standalone Python application designed to synchronize health and fitness data between the Fitbit and Garmin Connect platforms. The primary functions are to transfer weight data from Fitbit to Garmin, archive activity files from Garmin to a local directory, and download a wide range of Garmin health metrics for local storage and analysis.
## Features
- **Weight Data Synchronization**: Fetches weight history from Fitbit API and uploads to Garmin Connect
- **Activity File Archiving**: Downloads original activity files (.fit, .gpx, .tcx) from Garmin to local storage
- **Health Metrics Download**: Retrieves comprehensive health metrics from Garmin Connect
- **Web Interface**: Simple browser-based UI for triggering sync operations
- **Local-Only Storage**: All sensitive data stored locally with no external cloud services
## Prerequisites
- Python 3.11+
- PostgreSQL database
- Docker and Docker Compose (for containerized deployment)
- Fitbit Developer Account (to create an app and get API credentials)
- Garmin Connect Account
## Setup
### 1. Clone and Install Dependencies
```bash
# Clone the repository
git clone <repository-url>
cd fitbit-garmin-sync
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
### 2. Database Setup
```bash
# Create PostgreSQL database
createdb fitbit_garmin_sync
# Update database configuration in application
# The application will handle schema creation automatically
```
### 3. Environment Configuration
Create a `.env` file with the following:
```env
DATABASE_URL=postgresql://username:password@localhost:5432/fitbit_garmin_sync
FITBIT_CLIENT_ID=your_fitbit_client_id
FITBIT_CLIENT_SECRET=your_fitbit_client_secret
FITBIT_REDIRECT_URI=http://localhost:8000/api/setup/fitbit/callback
```
### 4. Run the Application
```bash
# Using uvicorn directly
uvicorn main:app --host 0.0.0.0 --port 8000
# Or using Docker
docker-compose up --build
```
## Initial Configuration
1. Open the application in your browser at `http://localhost:8000`
2. Navigate to the Setup page (`/setup`)
3. Enter your Garmin Connect username and password
4. Enter your Fitbit Client ID and Client Secret
5. Click the authorization link provided to authenticate with Fitbit
6. Copy the full callback URL from your browser after authorizing and paste it into the input field on the setup page
## Usage
### Sync Weight Data
1. Go to the home page (`/`)
2. Click the "Sync Weight" button
3. Monitor the sync status in the logs table
### Archive Activities
1. Go to the home page (`/`)
2. Click the "Sync Activities" button
3. Enter the number of days back to look for activities
4. Monitor the sync status in the logs table
### View Health Metrics
1. Use the API endpoints to query health metrics:
- `/api/metrics/list` - List available metric types
- `/api/metrics/query` - Query specific metrics
- `/api/health-data/summary` - Get aggregated health statistics
## API Endpoints
See the full API documentation in the `specs/001-fitbit-garmin-sync/contracts/api-contract.yaml` file or access the automatic documentation at `/docs` when running the application.
## Docker Deployment
```bash
# Build and run with Docker Compose
docker-compose up --build
# The application will be available at http://localhost:8000
# PostgreSQL database will be automatically set up
```
## Architecture
### Project Structure
```
backend/
├── main.py
├── src/
│ ├── models/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── weight_record.py
│ │ ├── activity.py
│ │ ├── health_metric.py
│ │ ├── sync_log.py
│ │ └── api_token.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── fitbit_client.py
│ │ ├── garmin_client.py
│ │ ├── postgresql_manager.py
│ │ └── sync_app.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ ├── sync.py
│ │ ├── setup.py
│ │ └── metrics.py
│ └── utils/
│ ├── __init__.py
│ └── helpers.py
├── templates/
│ ├── index.html
│ └── setup.html
├── static/
│ ├── css/
│ └── js/
├── requirements.txt
├── Dockerfile
└── docker-compose.yml
tests/
├── unit/
│ ├── test_models/
│ ├── test_services/
│ └── test_api/
├── integration/
│ └── test_sync_flow.py
└── contract/
└── test_api_contracts.py
```
## Development
For development, run the application with auto-reload:
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
## Security
- All API credentials and tokens are encrypted in the database
- OAuth2 flows are implemented following security best practices
- No external cloud services are used for data storage
- Implements rate limiting to be respectful to API providers

Binary file not shown.

View File

@@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url =
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1 @@
Generic single-database configuration.

View File

@@ -0,0 +1,80 @@
from logging.config import fileConfig
import os
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the database URL from environment variable if available
database_url = os.getenv("DATABASE_URL")
if database_url:
config.set_main_option("sqlalchemy.url", database_url)
# Import our models to ensure they are registered with Base
from src.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,151 @@
"""Initial migration
Revision ID: 24df1381ac00
Revises:
Create Date: 2025-12-22 15:04:54.280508
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '24df1381ac00'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Create configurations table
op.create_table('configurations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(), nullable=False),
sa.Column('value', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key')
)
op.create_index(op.f('ix_configurations_id'), 'configurations', ['id'], unique=False)
# Create api_tokens table
op.create_table('api_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('token_type', sa.String(), nullable=False),
sa.Column('access_token', sa.String(), nullable=False),
sa.Column('refresh_token', sa.String(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('scopes', sa.String(), nullable=True),
sa.Column('garth_oauth1_token', sa.String(), nullable=True),
sa.Column('garth_oauth2_token', sa.String(), nullable=True),
sa.Column('last_used', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_api_tokens_id'), 'api_tokens', ['id'], unique=False)
# Create auth_status table
op.create_table('auth_status',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('service', sa.String(), nullable=False),
sa.Column('is_authenticated', sa.Boolean(), nullable=False),
sa.Column('last_sync', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('service')
)
op.create_index(op.f('ix_auth_status_id'), 'auth_status', ['id'], unique=False)
# Create weight_records table
op.create_table('weight_records',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('weight', sa.Float(), nullable=False),
sa.Column('bmi', sa.Float(), nullable=True),
sa.Column('body_fat', sa.Float(), nullable=True),
sa.Column('source', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_weight_records_id'), 'weight_records', ['id'], unique=False)
# Create activities table
op.create_table('activities',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('activity_name', sa.String(), nullable=False),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('duration', sa.Integer(), nullable=True),
sa.Column('calories', sa.Integer(), nullable=True),
sa.Column('distance', sa.Float(), nullable=True),
sa.Column('source', sa.String(), nullable=False),
sa.Column('activity_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_activities_id'), 'activities', ['id'], unique=False)
# Create health_metrics table
op.create_table('health_metrics',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('metric_type', sa.String(), nullable=False),
sa.Column('value', sa.Float(), nullable=False),
sa.Column('unit', sa.String(), nullable=True),
sa.Column('source', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_health_metrics_id'), 'health_metrics', ['id'], unique=False)
# Create sync_logs table
op.create_table('sync_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sync_type', sa.String(), nullable=False),
sa.Column('source', sa.String(), nullable=False),
sa.Column('destination', sa.String(), nullable=False),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('records_synced', sa.Integer(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sync_logs_id'), 'sync_logs', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_sync_logs_id'), table_name='sync_logs')
op.drop_table('sync_logs')
op.drop_index(op.f('ix_health_metrics_id'), table_name='health_metrics')
op.drop_table('health_metrics')
op.drop_index(op.f('ix_activities_id'), table_name='activities')
op.drop_table('activities')
op.drop_index(op.f('ix_weight_records_id'), table_name='weight_records')
op.drop_table('weight_records')
op.drop_index(op.f('ix_auth_status_id'), table_name='auth_status')
op.drop_table('auth_status')
op.drop_index(op.f('ix_api_tokens_id'), table_name='api_tokens')
op.drop_table('api_tokens')
op.drop_index(op.f('ix_configurations_id'), table_name='configurations')
op.drop_table('configurations')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""add_mfa_session_fields_to_api_tokens
Revision ID: ce0f0282a142
Revises: 24df1381ac00
Create Date: 2025-12-22 18:06:20.525940
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ce0f0282a142'
down_revision: Union[str, None] = '24df1381ac00'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add MFA session columns to api_tokens table
op.add_column('api_tokens', sa.Column('mfa_session_id', sa.String(), nullable=True))
op.add_column('api_tokens', sa.Column('mfa_resume_data', sa.String(), nullable=True))
op.add_column('api_tokens', sa.Column('mfa_expires_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
# Remove MFA session columns from api_tokens table
op.drop_column('api_tokens', 'mfa_session_id')
op.drop_column('api_tokens', 'mfa_resume_data')
op.drop_column('api_tokens', 'mfa_expires_at')

View File

@@ -0,0 +1,31 @@
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/fitbit_garmin_sync
- FITBIT_CLIENT_ID=${FITBIT_CLIENT_ID:-}
- FITBIT_CLIENT_SECRET=${FITBIT_CLIENT_SECRET:-}
- FITBIT_REDIRECT_URI=${FITBIT_REDIRECT_URI:-http://localhost:8000/api/setup/fitbit/callback}
depends_on:
- db
volumes:
- ./data:/app/data # For activity files
- ./logs:/app/logs # For application logs
db:
image: postgres:15
environment:
- POSTGRES_DB=fitbit_garmin_sync
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

View File

@@ -0,0 +1,162 @@
# Fitbit-Garmin Sync Setup Guide
## Prerequisites
Before setting up the Fitbit-Garmin Sync application, ensure you have the following:
- Python 3.11+ installed
- PostgreSQL database server
- Docker and Docker Compose (for containerized deployment)
- Fitbit Developer Account to create an API application
- Garmin Connect Account
## Quick Setup with Docker
The easiest way to get started is using Docker Compose:
```bash
# Navigate to the backend directory
cd backend
# Start the application and database
docker-compose up --build
```
The application will be available at `http://localhost:8000`.
## Manual Setup
### 1. Clone the Repository
```bash
git clone <repository-url>
cd fitbit-garmin-sync
```
### 2. Create Virtual Environment
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
### 3. Install Dependencies
```bash
pip install -r requirements.txt
```
### 4. Database Setup
Create a PostgreSQL database for the application:
```bash
createdb fitbit_garmin_sync
```
### 5. Environment Configuration
Create a `.env` file in the backend directory with the following content:
```env
DATABASE_URL=postgresql://username:password@localhost:5432/fitbit_garmin_sync
FITBIT_CLIENT_ID=your_fitbit_client_id
FITBIT_CLIENT_SECRET=your_fitbit_client_secret
FITBIT_REDIRECT_URI=http://localhost:8000/api/setup/fitbit/callback
DEBUG=True
```
### 6. Run the Application
```bash
# Using uvicorn directly
uvicorn main:app --host 0.0.0.0 --port 8000
```
## Configuration Steps
Once the application is running, you'll need to configure both Fitbit and Garmin access:
### 1. Garmin Connect Setup
1. Navigate to the Setup page at `http://localhost:8000/setup`
2. Enter your Garmin Connect username and password
3. Click "Save Garmin Credentials"
### 2. Fitbit API Setup
1. Go to the [Fitbit Developer Portal](https://dev.fitbit.com/)
2. Create a new application
3. Set the OAuth 2.0 Application Type to "Personal"
4. Set the Callback URL to `http://localhost:8000/api/setup/fitbit/callback`
5. Note down the "Client ID" and "Client Secret"
6. On the setup page, enter these values in the Fitbit API Credentials section
7. Click "Save Fitbit Credentials"
8. Click the authorization link that appears to connect your Fitbit account
9. After authorizing, copy the complete URL from your browser and paste it in the "Complete Fitbit OAuth Flow" section
10. Click "Complete OAuth Flow"
## Running the Synchronization
### 1. Weight Sync
1. Go to the home page (`/`)
2. Click the "Sync Weight" button
3. Monitor the sync status in the logs table
### 2. Activity Archiving
1. Go to the home page (`/`)
2. Click the "Sync Activities" button
3. Enter the number of days back to look for activities
4. The original activity files (.fit, .gpx, .tcx) will be downloaded from Garmin and stored in the PostgreSQL database
5. Monitor the sync status in the logs table
6. Use the "List Stored Activities" and "Download Activity File" options to access stored activity files
### 3. Health Metrics Sync
1. Go to the home page (`/`)
2. Click the "Sync Health Metrics" button
3. Monitor the sync status in the logs table
## Security Considerations
- Store API credentials securely (not in version control)
- Use environment variables for configuration
- Encrypt sensitive data stored in the database
- Regularly rotate API tokens
- Implement proper error handling to avoid information disclosure
## Troubleshooting
### Common Issues
- **Database Connection**: Ensure PostgreSQL is running and accessible
- **API Credentials**: Verify Fitbit and Garmin credentials are correct
- **Rate Limits**: Be mindful of API rate limits from both providers
- **Network Issues**: Ensure the application has internet access
### Logs
Check the application logs for errors:
```bash
# In Docker
docker-compose logs app
# For direct Python execution, add logging to track operations
```
## Updating
To update to the latest version:
```bash
# Pull latest changes
git pull origin main
# Update dependencies
pip install -r requirements.txt
# Restart the application
```

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""
Script to initialize the database tables
"""
import os
from src.services.postgresql_manager import PostgreSQLManager
def init_database():
print("Initializing database...")
# Use the same DATABASE_URL as in the docker-compose
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync")
print(f"Using database URL: {database_url}")
db_manager = PostgreSQLManager(database_url=database_url)
db_manager.init_db()
print("Database initialized successfully!")
if __name__ == "__main__":
init_database()

View File

@@ -0,0 +1,61 @@
from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from src.services.postgresql_manager import PostgreSQLManager
from alembic.config import Config
from alembic import command
import os
# Create application lifespan to handle startup/shutdown
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
# Run database migrations
alembic_cfg = Config("alembic.ini")
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync")
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
command.upgrade(alembic_cfg, "head")
# Initialize database tables
db_manager = PostgreSQLManager(database_url=database_url)
db_manager.init_db()
yield
# Shutdown
# Add any cleanup code here if needed
# Create FastAPI app with lifespan
app = FastAPI(lifespan=lifespan)
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Initialize templates
templates = Jinja2Templates(directory="templates")
# Include API routes
from src.api.status import router as status_router
from src.api.sync import router as sync_router
from src.api.setup import router as setup_router
from src.api.logs import router as logs_router
from src.api.metrics import router as metrics_router
from src.api.activities import router as activities_router
app.include_router(status_router, prefix="/api")
app.include_router(sync_router, prefix="/api")
app.include_router(setup_router, prefix="/api")
app.include_router(logs_router, prefix="/api")
app.include_router(metrics_router, prefix="/api")
app.include_router(activities_router, prefix="/api")
from fastapi import Request
@app.get("/")
async def read_root(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/setup")
async def setup_page(request: Request):
return templates.TemplateResponse("setup.html", {"request": request})

View File

@@ -0,0 +1,34 @@
[tool.black]
line-length = 88
target-version = ['py311']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.venv
| venv
| build
| dist
| __pycache__
)/
'''
[tool.pytest.ini_options]
testpaths = ["backend/tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
asyncio_mode = "auto"
[tool.flake8]
max-line-length = 88
extend-ignore = ['E203', 'W503']
exclude = [
".git",
"__pycache__",
"build",
"dist",
".venv"
]

View File

@@ -0,0 +1,17 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
garminconnect==0.2.30
garth==0.5.17
fitbit==0.3.1
sqlalchemy==2.0.23
asyncpg==0.29.0
psycopg2-binary==2.9.9
jinja2==3.1.2
python-dotenv==1.0.0
pydantic==2.1.1
requests==2.31.0
httpx==0.25.2
aiofiles==23.2.1
pytest==7.4.3
pytest-asyncio==0.21.1
alembic==1.13.1

View File

View File

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Query, Response
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
router = APIRouter()
class ActivityResponse(BaseModel):
id: Optional[int] = None
garmin_activity_id: Optional[str] = None
activity_name: Optional[str] = None
activity_type: Optional[str] = None
start_time: Optional[str] = None
duration: Optional[int] = None
# file_path removed since we store in DB
file_type: Optional[str] = None
download_status: Optional[str] = None
downloaded_at: Optional[str] = None
@router.get("/activities/list", response_model=List[ActivityResponse])
async def list_activities(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0)
):
# This would return metadata for all downloaded/available activities
# Implementation will connect with the services layer
return []
@router.get("/activities/query", response_model=List[ActivityResponse])
async def query_activities(
activity_type: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
download_status: Optional[str] = Query(None)
):
# This would allow advanced filtering of activities
# Implementation will connect with the services layer
return []
@router.get("/activities/download/{activity_id}")
async def download_activity(activity_id: str):
# This would serve the stored activity file from the database
# Implementation will connect with the services layer
# It should return the file content with appropriate content-type
return Response(content=b"sample_content", media_type="application/octet-stream", headers={"Content-Disposition": f"attachment; filename=activity_{activity_id}.tcx"})

View File

@@ -0,0 +1,24 @@
from fastapi import APIRouter, Query
from pydantic import BaseModel
from typing import List, Optional
router = APIRouter()
class SyncLogResponse(BaseModel):
id: int
operation: str
status: str
message: Optional[str]
start_time: str
end_time: Optional[str]
records_processed: int
records_failed: int
@router.get("/logs", response_model=List[SyncLogResponse])
async def get_logs(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0)
):
# This would return sync logs
# Implementation will connect with the services layer
return []

View File

@@ -0,0 +1,98 @@
from fastapi import APIRouter, Query
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
router = APIRouter()
class HealthMetricResponse(BaseModel):
id: int
metric_type: str
metric_value: float
unit: Optional[str]
timestamp: str
date: str
source: str
detailed_data: Optional[Dict[str, Any]]
class MetricDateRange(BaseModel):
start_date: Optional[str]
end_date: Optional[str]
class MetricsListResponse(BaseModel):
metric_types: List[str]
date_range: MetricDateRange
class HealthDataSummary(BaseModel):
total_steps: Optional[int] = 0
avg_heart_rate: Optional[float] = 0.0
total_sleep_hours: Optional[float] = 0.0
avg_calories: Optional[float] = 0.0
class ActivityResponse(BaseModel):
id: Optional[int] = None
garmin_activity_id: Optional[str] = None
activity_name: Optional[str] = None
activity_type: Optional[str] = None
start_time: Optional[str] = None
duration: Optional[int] = None
file_path: Optional[str] = None
file_type: Optional[str] = None
download_status: Optional[str] = None
downloaded_at: Optional[str] = None
@router.get("/metrics/list", response_model=MetricsListResponse)
async def list_available_metrics():
# This would return available metric types and date ranges
# Implementation will connect with the services layer
return {
"metric_types": ["steps", "heart_rate", "sleep", "calories"],
"date_range": {
"start_date": "2023-01-01",
"end_date": "2023-12-31"
}
}
@router.get("/metrics/query", response_model=List[HealthMetricResponse])
async def query_metrics(
metric_type: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000)
):
# This would query health metrics with filters
# Implementation will connect with the services layer
return []
@router.get("/health-data/summary", response_model=HealthDataSummary)
async def get_health_summary(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None)
):
# This would return aggregated health statistics
# Implementation will connect with the services layer
return {
"total_steps": 123456,
"avg_heart_rate": 72.5,
"total_sleep_hours": 210.5,
"avg_calories": 2345.6
}
@router.get("/activities/list", response_model=List[ActivityResponse])
async def list_activities(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0)
):
# This would return metadata for all downloaded/available activities
# Implementation will connect with the services layer
return []
@router.get("/activities/query", response_model=List[ActivityResponse])
async def query_activities(
activity_type: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
download_status: Optional[str] = Query(None)
):
# This would allow advanced filtering of activities
# Implementation will connect with the services layer
return []

View File

@@ -0,0 +1,138 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from sqlalchemy.orm import Session
import traceback
from ..services.postgresql_manager import PostgreSQLManager
from ..utils.config import config
from ..services.garmin.client import GarminClient
router = APIRouter()
def get_db():
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
yield session
class GarminCredentials(BaseModel):
username: str
password: str
is_china: bool = False
class FitbitCredentials(BaseModel):
client_id: str
client_secret: str
class FitbitCallback(BaseModel):
callback_url: str
class GarminMFARequest(BaseModel):
verification_code: str
session_id: str
class AuthStatusResponse(BaseModel):
garmin: Optional[dict] = None
fitbit: Optional[dict] = None
@router.get("/setup/auth-status", response_model=AuthStatusResponse)
async def get_auth_status(db: Session = Depends(get_db)):
# This would return the current authentication status from the database
# Implementation will connect with the services layer
# For now, return placeholder until we have full implementation
return AuthStatusResponse(
garmin={
"username": "example@example.com",
"authenticated": False,
"token_expires_at": None,
"last_login": None,
"is_china": False
},
fitbit={
"client_id": "example_client_id",
"authenticated": False,
"token_expires_at": None,
"last_login": None
}
)
@router.post("/setup/garmin")
async def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)):
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
# This would save the Garmin credentials and attempt login
# Implementation will connect with the services layer
logger.info(f"Received Garmin credentials for user: {credentials.username}, is_china: {credentials.is_china}")
# Create the client with credentials but don't trigger login in __init__ if we handle it separately
garmin_client = GarminClient(credentials.username, credentials.password, credentials.is_china)
logger.debug("GarminClient instance created successfully")
try:
logger.debug("Attempting to log in to Garmin")
garmin_client.login()
# If login is successful, we're done
logger.info(f"Successfully authenticated Garmin user: {credentials.username}")
return {"status": "success", "message": "Garmin credentials saved and authenticated successfully"}
except Exception as e:
logger.error(f"Error during Garmin authentication: {str(e)}")
logger.error(f"Exception type: {type(e).__name__}")
logger.error(f"Exception details: {repr(e)}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
if "MFA" in str(e) or "mfa" in str(e).lower() or "MFA Required" in str(e):
logger.info("MFA required for Garmin authentication")
# Initiate MFA process and get session ID
session_id = garmin_client.initiate_mfa(credentials.username)
return {"status": "mfa_required", "message": "Multi-factor authentication required", "session_id": session_id}
else:
logger.error(f"Authentication failed with error: {str(e)}")
return {"status": "error", "message": f"Error during authentication: {str(e)}"}
@router.post("/setup/garmin/mfa")
async def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)):
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
# Complete the MFA process for Garmin using session ID
logger.info(f"Received MFA verification code for session {mfa_request.session_id}: {'*' * len(mfa_request.verification_code)}")
try:
# Create a basic Garmin client without credentials - we'll use the session data
garmin_client = GarminClient()
logger.debug(f"Attempting to handle MFA for session: {mfa_request.session_id}")
# Call the handle_mfa method which will use database-stored session data
success = garmin_client.handle_mfa(mfa_request.verification_code, session_id=mfa_request.session_id)
if success:
logger.info(f"MFA verification completed successfully for session: {mfa_request.session_id}")
return {"status": "success", "message": "MFA verification completed successfully"}
else:
logger.error(f"MFA verification failed for session: {mfa_request.session_id}")
return {"status": "error", "message": "MFA verification failed"}
except Exception as e:
logger.error(f"MFA verification failed for session {mfa_request.session_id} with exception: {str(e)}")
logger.error(f"Exception type: {type(e).__name__}")
logger.error(f"Exception details: {repr(e)}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
return {"status": "error", "message": f"MFA verification failed: {str(e)}"}
@router.post("/setup/fitbit")
async def save_fitbit_credentials(credentials: FitbitCredentials, db: Session = Depends(get_db)):
# This would save the Fitbit credentials and return auth URL
# Implementation will connect with the services layer
return {
"status": "success",
"auth_url": "https://www.fitbit.com/oauth2/authorize?...",
"message": "Fitbit credentials saved, please visit auth_url to authorize"
}
@router.post("/setup/fitbit/callback")
async def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db)):
# This would handle the Fitbit OAuth callback
# Implementation will connect with the services layer
return {"status": "success", "message": "Fitbit OAuth flow completed successfully"}

View File

@@ -0,0 +1,36 @@
from fastapi import APIRouter
from pydantic import BaseModel
from typing import List, Optional
router = APIRouter()
class SyncLogResponse(BaseModel):
id: int
operation: str
status: str
message: Optional[str]
start_time: str
end_time: Optional[str]
records_processed: int
records_failed: int
class StatusResponse(BaseModel):
total_weight_records: int
synced_weight_records: int
unsynced_weight_records: int
total_activities: int
downloaded_activities: int
recent_logs: List[SyncLogResponse]
@router.get("/status")
async def get_status():
# This would return the current sync status
# Implementation will connect with the services layer
return {
"total_weight_records": 100,
"synced_weight_records": 85,
"unsynced_weight_records": 15,
"total_activities": 50,
"downloaded_activities": 30,
"recent_logs": []
}

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional
from ..services.postgresql_manager import PostgreSQLManager
from sqlalchemy.orm import Session
from ..utils.config import config
router = APIRouter()
class SyncActivityRequest(BaseModel):
days_back: int = 30
class SyncResponse(BaseModel):
status: str
message: str
job_id: Optional[str] = None
def get_db():
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
yield session
@router.post("/sync/weight", response_model=SyncResponse)
async def sync_weight(db: Session = Depends(get_db)):
# This would trigger the weight sync process
# Implementation will connect with the services layer
return {
"status": "started",
"message": "Weight sync process started",
"job_id": "weight-sync-12345"
}
@router.post("/sync/activities", response_model=SyncResponse)
async def sync_activities(request: SyncActivityRequest, db: Session = Depends(get_db)):
# This would trigger the activity sync process
# Implementation will connect with the services layer
return {
"status": "started",
"message": "Activity sync process started",
"job_id": f"activity-sync-{request.days_back}"
}
@router.post("/sync/metrics", response_model=SyncResponse)
async def sync_metrics(db: Session = Depends(get_db)):
# This would trigger the health metrics sync process
# Implementation will connect with the services layer
return {
"status": "started",
"message": "Health metrics sync process started",
"job_id": "metrics-sync-12345"
}

View File

@@ -0,0 +1,13 @@
from sqlalchemy.ext.declarative import declarative_base
# Create a base class for all models to inherit from
Base = declarative_base()
# Import all models here to ensure they're registered with the Base
from .config import Configuration
from .api_token import APIToken
from .auth_status import AuthStatus
from .weight_record import WeightRecord
from .activity import Activity
from .health_metric import HealthMetric
from .sync_log import SyncLog

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, DateTime, Text, LargeBinary
from sqlalchemy.sql import func
from ..models import Base
class Activity(Base):
__tablename__ = "activities"
id = Column(Integer, primary_key=True, index=True)
garmin_activity_id = Column(String, unique=True, nullable=False) # Original Garmin ID
activity_name = Column(String, nullable=True) # Name of the activity
activity_type = Column(String, nullable=True) # Type of activity (e.g., 'running', 'cycling')
start_time = Column(DateTime, nullable=True) # Start time of the activity
duration = Column(Integer, nullable=True) # Duration in seconds
file_content = Column(LargeBinary, nullable=True) # Activity file content stored in database (base64 encoded)
file_type = Column(String, nullable=True) # File type (.fit, .gpx, .tcx, etc.)
download_status = Column(String, default='pending') # 'pending', 'downloaded', 'failed'
downloaded_at = Column(DateTime, nullable=True) # When downloaded
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,23 @@
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from ..models import Base
import json
class APIToken(Base):
__tablename__ = "api_tokens"
id = Column(Integer, primary_key=True, index=True)
token_type = Column(String, nullable=False) # 'fitbit' or 'garmin'
access_token = Column(String, nullable=False) # This should be encrypted in production
refresh_token = Column(String, nullable=True) # This should be encrypted in production
expires_at = Column(DateTime, nullable=False)
scopes = Column(String, nullable=True)
garth_oauth1_token = Column(String, nullable=True) # OAuth1 token for garmin (JSON)
garth_oauth2_token = Column(String, nullable=True) # OAuth2 token for garmin (JSON)
# MFA session fields for garmin
mfa_session_id = Column(String, nullable=True)
mfa_resume_data = Column(String, nullable=True) # JSON blob
mfa_expires_at = Column(DateTime, nullable=True)
last_used = Column(DateTime, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.sql import func
from ..models import Base
class AuthStatus(Base):
__tablename__ = "auth_status"
id = Column(Integer, primary_key=True, index=True)
service_type = Column(String, nullable=False) # 'fitbit' or 'garmin'
username = Column(String, nullable=True) # Masked username for security display
authenticated = Column(Boolean, default=False) # Whether currently authenticated
token_expires_at = Column(DateTime, nullable=True) # When current token expires
last_login = Column(DateTime, nullable=True) # Last successful login
is_china = Column(Boolean, default=False) # For Garmin - whether using garmin.cn domain
last_check = Column(DateTime, nullable=True) # When status was last checked
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, DateTime, JSON
from sqlalchemy.sql import func
from datetime import datetime
from ..models import Base
class Configuration(Base):
__tablename__ = "configurations"
id = Column(Integer, primary_key=True, index=True)
fitbit_client_id = Column(String, nullable=True)
fitbit_client_secret = Column(String, nullable=True) # This should be encrypted in production
garmin_username = Column(String, nullable=True)
garmin_password = Column(String, nullable=True) # This should be encrypted in production
sync_settings = Column(JSON, nullable=True) # JSON field for sync preferences
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, Text
from sqlalchemy.sql import func
from ..models import Base
class HealthMetric(Base):
__tablename__ = "health_metrics"
id = Column(Integer, primary_key=True, index=True)
metric_type = Column(String, nullable=False) # Type of metric (e.g., 'steps', 'heart_rate')
metric_value = Column(Float, nullable=False) # Value of the metric
unit = Column(String, nullable=True) # Unit of measurement
timestamp = Column(DateTime, nullable=False) # When the metric was recorded
date = Column(DateTime, nullable=False) # Date of the metric
source = Column(String, nullable=False) # Source of the metric ('garmin')
detailed_data = Column(Text, nullable=True) # Additional details (stored as JSON string)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,18 @@
from sqlalchemy import Column, Integer, String, DateTime, Text
from sqlalchemy.sql import func
from ..models import Base
class SyncLog(Base):
__tablename__ = "sync_logs"
id = Column(Integer, primary_key=True, index=True)
operation = Column(String, nullable=False) # 'weight_sync', 'activity_archive', 'metrics_download'
status = Column(String, nullable=False) # 'started', 'in_progress', 'completed', 'failed'
message = Column(Text, nullable=True) # Status message or error details
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=True) # When operation completed
records_processed = Column(Integer, default=0) # Number of records processed
records_failed = Column(Integer, default=0) # Number of records failed
user_id = Column(Integer, nullable=True) # Reference to user (if applicable)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime, Float
from sqlalchemy.sql import func
from ..models import Base
class WeightRecord(Base):
__tablename__ = "weight_records"
id = Column(Integer, primary_key=True, index=True)
fitbit_id = Column(String, unique=True, nullable=False) # Original Fitbit ID to prevent duplicates
weight = Column(Float, nullable=False) # Weight value
unit = Column(String, nullable=False) # Unit (e.g., 'kg', 'lbs')
date = Column(DateTime, nullable=False) # Date of measurement
timestamp = Column(DateTime, nullable=False) # Exact timestamp
sync_status = Column(String, default='unsynced') # 'unsynced', 'synced', 'failed'
garmin_id = Column(String, nullable=True) # ID of record if synced to Garmin
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,74 @@
import fitbit
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
import logging
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
class FitbitClient:
def __init__(self, client_id: str, client_secret: str, access_token: str = None, refresh_token: str = None):
self.client_id = client_id
self.client_secret = client_secret
self.access_token = access_token
self.refresh_token = refresh_token
self.fitbit_client = None
if access_token and refresh_token:
self.fitbit_client = fitbit.Fitbit(
client_id=client_id,
client_secret=client_secret,
access_token=access_token,
refresh_token=refresh_token,
# Callback for token refresh if needed
)
def get_authorization_url(self, redirect_uri: str) -> str:
"""Generate authorization URL for Fitbit OAuth flow."""
# This would generate the Fitbit authorization URL
auth_url = f"https://www.fitbit.com/oauth2/authorize?response_type=code&client_id={self.client_id}&redirect_uri={redirect_uri}&scope=weight"
logger.info(f"Generated Fitbit authorization URL: {auth_url}")
return auth_url
def exchange_code_for_token(self, code: str, redirect_uri: str) -> Dict[str, str]:
"""Exchange authorization code for access and refresh tokens."""
# This would exchange the authorization code for tokens
# Implementation would use the Fitbit library to exchange the code
logger.info(f"Exchanging authorization code for tokens")
# Return mock response for now
return {
"access_token": "mock_access_token",
"refresh_token": "mock_refresh_token",
"expires_at": (datetime.now() + timedelta(hours=1)).isoformat()
}
def get_weight_logs(self, start_date: str, end_date: str = None) -> List[Dict[str, Any]]:
"""Fetch weight logs from Fitbit API."""
if not self.fitbit_client:
raise Exception("Fitbit client not authenticated")
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
try:
# Get weight logs from Fitbit
weight_logs = self.fitbit_client.get_bodyweight(
base_date=start_date,
end_date=end_date
)
logger.info(f"Fetched {len(weight_logs.get('weight', []))} weight entries from Fitbit")
return weight_logs.get('weight', [])
except Exception as e:
logger.error(f"Error fetching weight logs from Fitbit: {str(e)}")
raise e
def refresh_access_token(self) -> Dict[str, str]:
"""Refresh the Fitbit access token."""
# Implementation for token refresh
logger.info("Refreshing Fitbit access token")
# Return mock response for now
return {
"access_token": "new_mock_access_token",
"expires_at": (datetime.now() + timedelta(hours=1)).isoformat()
}

View File

@@ -0,0 +1,217 @@
import garth
import garminconnect
from datetime import datetime, timedelta
from uuid import uuid4
import json
import traceback
from src.utils.helpers import setup_logger
from src.models.api_token import APIToken
from src.services.postgresql_manager import PostgreSQLManager
from src.utils.config import config
logger = setup_logger(__name__)
class AuthMixin:
def login(self):
"""Login to Garmin Connect with proper token handling."""
logger.info(f"Starting login process for Garmin user: {self.username}")
try:
logger.debug(f"Attempting garth login for user: {self.username}")
garth.login(self.username, self.password, return_on_mfa=True)
logger.debug(f"Successfully completed garth authentication for: {self.username}")
logger.debug(f"Creating Garmin Connect client for user: {self.username}")
self.garmin_client = garminconnect.Garmin(self.username, self.password)
self.garmin_client.garth = garth.client
logger.debug(f"Successfully created Garmin Connect client for user: {self.username}")
self.is_connected = True
logger.info(f"Setting is_connected to True for user: {self.username}")
self.save_tokens()
logger.info(f"Successfully logged in to Garmin Connect as {self.username}")
except Exception as e:
logger.error(f"Error logging in to Garmin Connect: {str(e)}")
logger.error(f"Exception type: {type(e).__name__}")
error_str = str(e).lower()
if "mfa" in error_str or "2fa" in error_str or "unauthorized" in error_str:
logger.warning(f"Multi-factor authentication likely required for {self.username}")
logger.debug(f"Detected MFA indicator in error message: {error_str}")
raise Exception("MFA Required: Please provide verification code")
logger.error(f"Full traceback: {traceback.format_exc()}")
raise e
def save_tokens(self):
"""Save garth tokens to be used later."""
logger.info(f"Starting token saving process for user: {self.username}")
try:
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter(APIToken.token_type == 'garmin').first()
if not token_record:
token_record = APIToken(token_type='garmin')
session.add(token_record)
oauth1_token = getattr(garth.client, 'oauth1_token', None)
oauth2_token = getattr(garth.client, 'oauth2_token', None)
if oauth1_token:
try:
token_dict = oauth1_token.__dict__ if hasattr(oauth1_token, '__dict__') else str(oauth1_token)
token_record.garth_oauth1_token = json.dumps(token_dict, default=str)
except Exception as e:
logger.warning(f"Could not serialize OAuth1 token for user {self.username}: {e}")
if oauth2_token:
try:
token_dict = oauth2_token.__dict__ if hasattr(oauth2_token, '__dict__') else str(oauth2_token)
token_record.garth_oauth2_token = json.dumps(token_dict, default=str)
except Exception as e:
logger.warning(f"Could not serialize OAuth2 token for user {self.username}: {e}")
session.commit()
logger.info(f"Garmin tokens saved successfully for user: {self.username}")
except Exception as e:
logger.error(f"Error saving garth tokens for user {self.username}: {str(e)}")
raise e
def load_tokens(self):
"""Load garth tokens to resume a session."""
logger.info(f"Starting token loading process for user: {self.username}")
try:
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
try:
token_record = session.query(APIToken).filter(APIToken.token_type == 'garmin').first()
except Exception as db_error:
logger.info(f"No existing Garmin tokens found for user {self.username} or table doesn't exist: {db_error}")
return False
if not token_record or (not token_record.garth_oauth1_token and not token_record.garth_oauth2_token):
logger.info(f"No Garmin token record found in database for user: {self.username}")
return False
if token_record.garth_oauth1_token:
try:
oauth1_data = json.loads(token_record.garth_oauth1_token)
setattr(garth.client, 'oauth1_token', oauth1_data)
logger.info(f"Successfully restored OAuth1 token for user: {self.username}")
except Exception as e:
logger.warning(f"Could not restore OAuth1 token for user {self.username}: {e}")
if token_record.garth_oauth2_token:
try:
oauth2_data = json.loads(token_record.garth_oauth2_token)
setattr(garth.client, 'oauth2_token', oauth2_data)
logger.info(f"Successfully restored OAuth2 token for user: {self.username}")
self.garmin_client = garminconnect.Garmin(self.username, self.password)
self.garmin_client.garth = garth.client
self.is_connected = True
logger.debug(f"Successfully created Garmin Connect client for user {self.username} with restored session")
return True
except Exception as e:
logger.warning(f"Could not restore OAuth2 token for user {self.username}: {e}")
return True
except Exception as e:
logger.error(f"Error loading garth tokens for user {self.username}: {str(e)}")
return False
def initiate_mfa(self, username: str = None):
"""Initiate the MFA process and return session data."""
user_identifier = username if username else self.username
logger.info(f"Initiating MFA process for Garmin user: {user_identifier}")
mfa_session_id = str(uuid4())
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter(APIToken.token_type == 'garmin').first()
if not token_record:
token_record = APIToken(token_type='garmin')
session.add(token_record)
token_record.mfa_session_id = mfa_session_id
resume_data = {
'username': user_identifier,
'password': self.password,
'is_china': self.is_china
}
token_record.mfa_resume_data = json.dumps(resume_data)
token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10)
session.commit()
logger.info(f"MFA session initiated for user: {user_identifier}, session ID: {mfa_session_id}")
return mfa_session_id
def handle_mfa(self, verification_code: str, session_id: str = None):
"""Handle the MFA process by completing authentication with the verification code."""
logger.info(f"Starting MFA completion process with session ID: {session_id}")
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter(
APIToken.token_type == 'garmin',
APIToken.mfa_session_id == session_id
).first()
if not token_record:
raise Exception("No pending MFA authentication for this session.")
if token_record.mfa_expires_at and datetime.now() > token_record.mfa_expires_at:
self.cleanup_mfa_session(token_record, session)
raise Exception("MFA verification code has expired.")
try:
resume_data = json.loads(token_record.mfa_resume_data)
self.username = resume_data.get('username')
self.password = resume_data.get('password')
if resume_data.get('is_china', False):
garth.configure(domain="garmin.cn")
try:
garth.client.mfa_submit(verification_code)
except AttributeError:
garth.login(self.username, self.password, verification_code)
self.garmin_client = garminconnect.Garmin(self.username, self.password)
self.garmin_client.garth = garth.client
try:
profile = self.garmin_client.get_full_name()
logger.info(f"Verified authentication for user: {profile}")
except Exception as verify_error:
logger.warning(f"Could not verify authentication for user {self.username}: {verify_error}")
self.is_connected = True
self.save_tokens()
self.cleanup_mfa_session(token_record, session)
logger.info(f"Successfully completed MFA authentication for {self.username}")
return True
except Exception as e:
logger.error(f"Error during MFA completion for user {self.username}: {e}")
self.cleanup_mfa_session(token_record, session)
raise e
def cleanup_mfa_session(self, token_record, session):
"""Clear out MFA session data from the token record."""
token_record.mfa_session_id = None
token_record.mfa_resume_data = None
token_record.mfa_expires_at = None
session.commit()
logger.debug("MFA session data cleaned up.")

View File

@@ -0,0 +1,39 @@
import garth
from src.utils.helpers import setup_logger
from .auth import AuthMixin
from .data import DataMixin
logger = setup_logger(__name__)
class GarminClient(AuthMixin, DataMixin):
def __init__(self, username: str = None, password: str = None, is_china: bool = False):
self.username = username
self.password = password
self.is_china = is_china
self.garmin_client = None
self.is_connected = False
logger.debug(f"Initializing GarminClient for user: {username}, is_china: {is_china}")
if is_china:
logger.debug("Configuring garth for China domain")
garth.configure(domain="garmin.cn")
if username and password:
logger.info(f"Attempting to authenticate Garmin user: {username}")
if not self.load_tokens():
logger.info("No valid tokens found, attempting fresh login")
self.login()
else:
logger.info("Successfully loaded existing tokens, skipping fresh login")
else:
logger.debug("No username/password provided during initialization")
def check_connection(self) -> bool:
"""Check if the connection to Garmin is still valid."""
try:
profile = self.garmin_client.get_full_name() if self.garmin_client else None
return profile is not None
except:
self.is_connected = False
return False

View File

@@ -0,0 +1,139 @@
from datetime import datetime
from typing import List, Dict, Any, Optional
from src.utils.helpers import setup_logger
logger = setup_logger(__name__)
class DataMixin:
def upload_weight(self, weight: float, unit: str = 'kg', timestamp: datetime = None) -> bool:
"""Upload weight entry to Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not timestamp:
timestamp = datetime.now()
try:
result = self.garmin_client.add_body_composition(
timestamp=timestamp,
weight=weight
)
except Exception:
try:
result = self.garmin_client.add_body_composition(
timestamp=timestamp.isoformat(),
weight=weight
)
except Exception:
result = self.garmin_client.add_body_composition(
timestamp=timestamp.strftime('%Y-%m-%d'),
weight=weight
)
logger.info(f"Successfully uploaded weight: {weight} {unit} at {timestamp}")
return result is not None
except Exception as e:
logger.error(f"Error uploading weight to Garmin: {str(e)}")
if "401" in str(e) or "unauthorized" in str(e).lower():
logger.error("Authentication failed - need to re-authenticate")
raise Exception("Authentication expired, needs re-authentication")
raise e
def get_activities(self, start_date: str, end_date: str = None, limit: int = 100) -> List[Dict[str, Any]]:
"""Fetch activity list from Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
activities = self.garmin_client.get_activities(start_date, end_date)
logger.info(f"Fetched {len(activities)} activities from Garmin")
return activities
except Exception as e:
logger.error(f"Error fetching activities from Garmin: {str(e)}")
raise e
def download_activity(self, activity_id: str, file_type: str = 'tcx') -> Optional[bytes]:
"""Download activity file from Garmin Connect and return its content."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
file_content = self.garmin_client.get_activity_details(activity_id)
logger.info(f"Downloaded activity {activity_id} as {file_type} format")
return file_content if file_content else b""
except Exception as e:
logger.error(f"Error downloading activity {activity_id} from Garmin: {str(e)}")
raise e
def get_heart_rates(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
"""Fetch heart rate data from Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
heart_rates = self.garmin_client.get_heart_rates(start_date, end_date)
logger.info(f"Fetched heart rate data from Garmin for {start_date} to {end_date}")
return heart_rates
except Exception as e:
logger.error(f"Error fetching heart rate data from Garmin: {str(e)}")
raise e
def get_sleep_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
"""Fetch sleep data from Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
sleep_data = self.garmin_client.get_sleep_data(start_date, end_date)
logger.info(f"Fetched sleep data from Garmin for {start_date} to {end_date}")
return sleep_data
except Exception as e:
logger.error(f"Error fetching sleep data from Garmin: {str(e)}")
raise e
def get_steps_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
"""Fetch steps data from Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
steps_data = self.garmin_client.get_steps_data(start_date, end_date)
logger.info(f"Fetched steps data from Garmin for {start_date} to {end_date}")
return steps_data
except Exception as e:
logger.error(f"Error fetching steps data from Garmin: {str(e)}")
raise e
def get_all_metrics(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
"""Fetch all available metrics from Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
metrics = {
'heart_rates': self.get_heart_rates(start_date, end_date),
'sleep_data': self.get_sleep_data(start_date, end_date),
'steps_data': self.get_steps_data(start_date, end_date),
}
logger.info(f"Fetched all metrics from Garmin for {start_date} to {end_date}")
return metrics
except Exception as e:
logger.error(f"Error fetching all metrics from Garmin: {str(e)}")
raise e

View File

@@ -0,0 +1,47 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool
import os
from contextlib import contextmanager
# Create a base class for declarative models
Base = declarative_base()
class PostgreSQLManager:
def __init__(self, database_url: str = None):
if database_url is None:
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync")
self.engine = create_engine(
database_url,
poolclass=QueuePool,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=300,
)
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
def init_db(self):
"""Initialize the database by creating all tables."""
# Import all models to ensure they're registered with the Base
from ..models.config import Configuration
from ..models.api_token import APIToken
from ..models.auth_status import AuthStatus
from ..models.weight_record import WeightRecord
from ..models.activity import Activity
from ..models.health_metric import HealthMetric
from ..models.sync_log import SyncLog
# Create all tables
Base.metadata.create_all(bind=self.engine)
@contextmanager
def get_db_session(self):
"""Provide a database session."""
db = self.SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,322 @@
from ..models.weight_record import WeightRecord
from ..models.sync_log import SyncLog
from ..services.fitbit_client import FitbitClient
from ..services.garmin.client import GarminClient
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from typing import Dict
import logging
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
class SyncApp:
def __init__(self, db_session: Session, fitbit_client: FitbitClient, garmin_client: GarminClient):
self.db_session = db_session
self.fitbit_client = fitbit_client
self.garmin_client = garmin_client
def sync_weight_data(self, start_date: str = None, end_date: str = None) -> Dict[str, int]:
"""Sync weight data from Fitbit to Garmin."""
if not start_date:
# Default to 1 year back
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
# Create a sync log entry
sync_log = SyncLog(
operation="weight_sync",
status="started",
start_time=datetime.now(),
records_processed=0,
records_failed=0
)
self.db_session.add(sync_log)
self.db_session.commit()
try:
# Fetch unsynced weight records from Fitbit
fitbit_weights = self.fitbit_client.get_weight_logs(start_date, end_date)
# Track processing results
processed_count = 0
failed_count = 0
for weight_entry in fitbit_weights:
try:
# Check if this weight entry already exists in our DB (prevents duplicates)
fitbit_id = weight_entry.get('logId', str(weight_entry.get('date', '') + str(weight_entry.get('weight', 0))))
existing_record = self.db_session.query(WeightRecord).filter(
WeightRecord.fitbit_id == fitbit_id
).first()
if existing_record and existing_record.sync_status == 'synced':
# Skip if already synced
continue
# Create or update weight record
if not existing_record:
weight_record = WeightRecord(
fitbit_id=fitbit_id,
weight=weight_entry.get('weight'),
unit=weight_entry.get('unit', 'kg'),
date=datetime.fromisoformat(weight_entry.get('date')) if isinstance(weight_entry.get('date'), str) else weight_entry.get('date'),
timestamp=datetime.fromisoformat(weight_entry.get('date')) if isinstance(weight_entry.get('date'), str) else weight_entry.get('date'),
sync_status='unsynced'
)
self.db_session.add(weight_record)
self.db_session.flush() # Get the ID
else:
weight_record = existing_record
# Upload to Garmin if not already synced
if weight_record.sync_status != 'synced':
# Upload weight to Garmin
success = self.garmin_client.upload_weight(
weight=weight_record.weight,
unit=weight_record.unit,
timestamp=weight_record.timestamp
)
if success:
weight_record.sync_status = 'synced'
weight_record.garmin_id = "garmin_" + fitbit_id # Placeholder for Garmin ID
else:
weight_record.sync_status = 'failed'
failed_count += 1
processed_count += 1
except Exception as e:
logger.error(f"Error processing weight entry: {str(e)}")
failed_count += 1
# Update sync log with results
sync_log.status = "completed" if failed_count == 0 else "completed_with_errors"
sync_log.end_time = datetime.now()
sync_log.records_processed = processed_count
sync_log.records_failed = failed_count
self.db_session.commit()
logger.info(f"Weight sync completed: {processed_count} processed, {failed_count} failed")
return {
"processed": processed_count,
"failed": failed_count
}
except Exception as e:
logger.error(f"Error during weight sync: {str(e)}")
# Update sync log with error status
sync_log.status = "failed"
sync_log.end_time = datetime.now()
sync_log.message = str(e)
self.db_session.commit()
raise e
def sync_activities(self, days_back: int = 30) -> Dict[str, int]:
"""Sync activity data from Garmin to local storage."""
start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d')
end_date = datetime.now().strftime('%Y-%m-%d')
# Create a sync log entry
sync_log = SyncLog(
operation="activity_archive",
status="started",
start_time=datetime.now(),
records_processed=0,
records_failed=0
)
self.db_session.add(sync_log)
self.db_session.commit()
try:
# Fetch activities from Garmin
garmin_activities = self.garmin_client.get_activities(start_date, end_date)
processed_count = 0
failed_count = 0
from ..models.activity import Activity
for activity in garmin_activities:
try:
activity_id = str(activity.get('activityId', ''))
existing_activity = self.db_session.query(Activity).filter(
Activity.garmin_activity_id == activity_id
).first()
if existing_activity and existing_activity.download_status == 'downloaded':
# Skip if already downloaded
continue
# Create or update activity record
if not existing_activity:
activity_record = Activity(
garmin_activity_id=activity_id,
activity_name=activity.get('activityName', ''),
activity_type=activity.get('activityType', ''),
start_time=datetime.fromisoformat(activity.get('startTimeLocal', '')) if activity.get('startTimeLocal') else None,
duration=activity.get('duration', 0),
download_status='pending'
)
self.db_session.add(activity_record)
self.db_session.flush()
else:
activity_record = existing_activity
# Download activity file if not already downloaded
if activity_record.download_status != 'downloaded':
# Download in various formats
file_formats = ['tcx', 'gpx', 'fit']
downloaded_successfully = False
for fmt in file_formats:
try:
# Get file content from Garmin client
file_content = self.garmin_client.download_activity(activity_id, file_type=fmt)
if file_content:
# Store file content directly in the database
activity_record.file_content = file_content
activity_record.file_type = fmt
activity_record.download_status = 'downloaded'
activity_record.downloaded_at = datetime.now()
downloaded_successfully = True
break
except Exception as e:
logger.warning(f"Could not download activity {activity_id} in {fmt} format: {str(e)}")
continue
if not downloaded_successfully:
activity_record.download_status = 'failed'
failed_count += 1
processed_count += 1
except Exception as e:
logger.error(f"Error processing activity {activity.get('activityId', '')}: {str(e)}")
failed_count += 1
# Update sync log with results
sync_log.status = "completed" if failed_count == 0 else "completed_with_errors"
sync_log.end_time = datetime.now()
sync_log.records_processed = processed_count
sync_log.records_failed = failed_count
self.db_session.commit()
logger.info(f"Activity sync completed: {processed_count} processed, {failed_count} failed")
return {
"processed": processed_count,
"failed": failed_count
}
except Exception as e:
logger.error(f"Error during activity sync: {str(e)}")
# Update sync log with error status
sync_log.status = "failed"
sync_log.end_time = datetime.now()
sync_log.message = str(e)
self.db_session.commit()
raise e
def sync_health_metrics(self, start_date: str = None, end_date: str = None) -> Dict[str, int]:
"""Sync health metrics from Garmin to local database."""
if not start_date:
# Default to 1 year back
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
# Create a sync log entry
sync_log = SyncLog(
operation="metrics_download",
status="started",
start_time=datetime.now(),
records_processed=0,
records_failed=0
)
self.db_session.add(sync_log)
self.db_session.commit()
try:
# Fetch all metrics from Garmin
all_metrics = self.garmin_client.get_all_metrics(start_date, end_date)
processed_count = 0
failed_count = 0
from ..models.health_metric import HealthMetric
# Process heart rate data
heart_rates = all_metrics.get('heart_rates', {})
if 'heartRateValues' in heart_rates:
for hr_data in heart_rates['heartRateValues']:
try:
timestamp = datetime.fromisoformat(hr_data[0]) if isinstance(hr_data[0], str) else datetime.fromtimestamp(hr_data[0]/1000)
metric = HealthMetric(
metric_type='heart_rate',
metric_value=hr_data[1],
unit='bpm',
timestamp=timestamp,
date=timestamp.date(),
source='garmin',
detailed_data=None
)
self.db_session.add(metric)
processed_count += 1
except Exception as e:
logger.error(f"Error processing heart rate data: {str(e)}")
failed_count += 1
# Process other metrics similarly...
# For brevity, I'll show just one more example
sleep_data = all_metrics.get('sleep_data', {})
sleep_levels = sleep_data.get('sleep', [])
for sleep_entry in sleep_levels:
try:
metric = HealthMetric(
metric_type='sleep',
metric_value=sleep_entry.get('duration', 0),
unit='minutes',
timestamp=datetime.now(), # Actual timestamp would come from data
date=datetime.now().date(), # Actual date would come from data
source='garmin',
detailed_data=sleep_entry
)
self.db_session.add(metric)
processed_count += 1
except Exception as e:
logger.error(f"Error processing sleep data: {str(e)}")
failed_count += 1
# Update sync log with results
sync_log.status = "completed" if failed_count == 0 else "completed_with_errors"
sync_log.end_time = datetime.now()
sync_log.records_processed = processed_count
sync_log.records_failed = failed_count
self.db_session.commit()
logger.info(f"Health metrics sync completed: {processed_count} processed, {failed_count} failed")
return {
"processed": processed_count,
"failed": failed_count
}
except Exception as e:
logger.error(f"Error during health metrics sync: {str(e)}")
# Update sync log with error status
sync_log.status = "failed"
sync_log.end_time = datetime.now()
sync_log.message = str(e)
self.db_session.commit()
raise e

View File

@@ -0,0 +1,37 @@
import os
from typing import Optional
from dataclasses import dataclass
@dataclass
class Config:
"""Configuration class to store environment variables."""
# Database
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync")
# Fitbit
FITBIT_CLIENT_ID: Optional[str] = os.getenv("FITBIT_CLIENT_ID")
FITBIT_CLIENT_SECRET: Optional[str] = os.getenv("FITBIT_CLIENT_SECRET")
FITBIT_REDIRECT_URI: str = os.getenv("FITBIT_REDIRECT_URI", "http://localhost:8000/api/setup/fitbit/callback")
# Garmin
GARMIN_USERNAME: Optional[str] = os.getenv("GARMIN_USERNAME")
GARMIN_PASSWORD: Optional[str] = os.getenv("GARMIN_PASSWORD")
# Server
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))
# App
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
def validate(self) -> bool:
"""Validate that required environment variables are set."""
required_vars = ['DATABASE_URL']
for var in required_vars:
if not getattr(self, var):
raise ValueError(f"Missing required environment variable: {var}")
return True
# Create a global config instance
config = Config()

View File

@@ -0,0 +1,36 @@
import logging
from datetime import datetime
from typing import Optional
import os
def setup_logger(name: str, level=logging.DEBUG):
"""Function to setup a logger that writes to the console."""
formatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s %(message)s')
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger = logging.getLogger(name)
logger.setLevel(level)
if not logger.handlers:
logger.addHandler(console_handler)
return logger
def get_current_timestamp() -> str:
"""Get current timestamp in ISO format."""
return datetime.utcnow().isoformat()
def validate_environment_vars(required_vars: list) -> bool:
"""Validate that required environment variables are set."""
missing_vars = []
for var in required_vars:
if not os.getenv(var):
missing_vars.append(var)
if missing_vars:
print(f"Missing required environment variables: {', '.join(missing_vars)}")
return False
return True

View File

@@ -0,0 +1,308 @@
<!DOCTYPE html>
<html>
<head>
<title>Fitbit-Garmin Sync Dashboard</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>Fitbit-Garmin Sync Dashboard</h1>
<div class="row mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Weight Records</h5>
<p class="card-text">Total: <span id="total-weights">0</span></p>
<p class="card-text">Synced: <span id="synced-weights">0</span></p>
<p class="card-text">Unsynced: <span id="unsynced-weights">0</span></p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Activities</h5>
<p class="card-text">Total: <span id="total-activities">0</span></p>
<p class="card-text">Downloaded: <span id="downloaded-activities">0</span></p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sync Status</h5>
<div class="d-grid gap-2">
<button class="btn btn-primary" type="button" id="sync-weight-btn">Sync Weight</button>
<button class="btn btn-secondary" type="button" id="sync-activities-btn">Sync Activities</button>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h3>Recent Sync Logs</h3>
<div class="table-responsive">
<table class="table table-striped" id="sync-logs-table">
<thead>
<tr>
<th>Operation</th>
<th>Status</th>
<th>Start Time</th>
<th>Records Processed</th>
<th>Records Failed</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5">Loading logs...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<h3>Health Metrics</h3>
<div class="card">
<div class="card-body">
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
<button class="btn btn-info me-md-2" type="button" id="sync-metrics-btn">Sync Health Metrics</button>
<button class="btn btn-outline-info me-md-2" type="button" id="view-metrics-btn">View Health Data Summary</button>
<button class="btn btn-outline-info" type="button" id="query-metrics-btn">Query Metrics</button>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<h3>Activity Files</h3>
<div class="card">
<div class="card-body">
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
<button class="btn btn-outline-secondary me-md-2" type="button" id="list-activities-btn">List Stored Activities</button>
<button class="btn btn-outline-secondary" type="button" id="download-activities-btn">Download Activity File</button>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
<a href="/setup" class="btn btn-primary me-md-2">Setup & Configuration</a>
<a href="/docs" class="btn btn-outline-secondary" target="_blank">API Documentation</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<h3>Health Metrics</h3>
<div class="card">
<div class="card-body">
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
<button class="btn btn-info me-md-2" type="button" id="sync-metrics-btn">Sync Health Metrics</button>
<button class="btn btn-outline-info me-md-2" type="button" id="view-metrics-btn">View Health Data Summary</button>
<button class="btn btn-outline-info" type="button" id="query-metrics-btn">Query Metrics</button>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<h3>Activity Files</h3>
<div class="card">
<div class="card-body">
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
<button class="btn btn-outline-secondary me-md-2" type="button" id="list-activities-btn">List Stored Activities</button>
<button class="btn btn-outline-secondary" type="button" id="download-activities-btn">Download Activity File</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Load dashboard data when page loads
document.addEventListener('DOMContentLoaded', function() {
loadDashboardData();
// Set up sync buttons
document.getElementById('sync-weight-btn').addEventListener('click', syncWeight);
document.getElementById('sync-activities-btn').addEventListener('click', syncActivities);
// Set up metrics buttons
document.getElementById('sync-metrics-btn').addEventListener('click', syncHealthMetrics);
document.getElementById('view-metrics-btn').addEventListener('click', viewHealthSummary);
document.getElementById('query-metrics-btn').addEventListener('click', queryMetrics);
// Set up activity file buttons
document.getElementById('list-activities-btn').addEventListener('click', listActivities);
document.getElementById('download-activities-btn').addEventListener('click', downloadActivityFile);
});
async function loadDashboardData() {
try {
const response = await fetch('/api/status');
const data = await response.json();
document.getElementById('total-weights').textContent = data.total_weight_records;
document.getElementById('synced-weights').textContent = data.synced_weight_records;
document.getElementById('unsynced-weights').textContent = data.unsynced_weight_records;
document.getElementById('total-activities').textContent = data.total_activities;
document.getElementById('downloaded-activities').textContent = data.downloaded_activities;
// Update logs table
const logsBody = document.querySelector('#sync-logs-table tbody');
logsBody.innerHTML = '';
data.recent_logs.forEach(log => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${log.operation}</td>
<td>${log.status}</td>
<td>${log.start_time}</td>
<td>${log.records_processed}</td>
<td>${log.records_failed}</td>
`;
logsBody.appendChild(row);
});
} catch (error) {
console.error('Error loading dashboard data:', error);
}
}
async function syncWeight() {
try {
const response = await fetch('/api/sync/weight', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
alert(`Weight sync initiated: ${data.message}`);
// Refresh dashboard data
loadDashboardData();
} catch (error) {
console.error('Error syncing weight:', error);
alert('Error initiating weight sync: ' + error.message);
}
}
async function syncActivities() {
try {
const response = await fetch('/api/sync/activities', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ days_back: 30 })
});
const data = await response.json();
alert(`Activity sync initiated: ${data.message}`);
// Refresh dashboard data
loadDashboardData();
} catch (error) {
console.error('Error syncing activities:', error);
alert('Error initiating activity sync: ' + error.message);
}
}
async function syncHealthMetrics() {
try {
const response = await fetch('/api/sync/metrics', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
alert(`Health metrics sync initiated: ${data.message}`);
} catch (error) {
console.error('Error syncing health metrics:', error);
alert('Error initiating health metrics sync: ' + error.message);
}
}
async function viewHealthSummary() {
try {
const response = await fetch('/api/health-data/summary');
const data = await response.json();
alert(`Health Summary:
Steps: ${data.total_steps || 0}
Avg Heart Rate: ${data.avg_heart_rate || 0}
Sleep Hours: ${data.total_sleep_hours || 0}
Avg Calories: ${data.avg_calories || 0}`);
} catch (error) {
console.error('Error fetching health summary:', error);
alert('Error fetching health summary: ' + error.message);
}
}
async function queryMetrics() {
try {
const response = await fetch('/api/metrics/query');
const data = await response.json();
alert(`Found ${data.length} health metrics`);
} catch (error) {
console.error('Error querying metrics:', error);
alert('Error querying metrics: ' + error.message);
}
}
async function listActivities() {
try {
const response = await fetch('/api/activities/list');
const data = await response.json();
alert(`Found ${data.length} stored activities`);
} catch (error) {
console.error('Error listing activities:', error);
alert('Error listing activities: ' + error.message);
}
}
async function downloadActivityFile() {
try {
// For demo purposes, we'll use a placeholder ID
// In a real implementation, this would prompt for activity ID or list available activities
const activityId = prompt('Enter activity ID to download:', '12345');
if (activityId) {
// This would initiate a download of the stored activity file
window.open(`/api/activities/download/${activityId}`, '_blank');
}
} catch (error) {
console.error('Error downloading activity file:', error);
alert('Error downloading activity file: ' + error.message);
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html>
<head>
<title>Fitbit-Garmin Sync - Setup</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>Fitbit-Garmin Sync - Setup</h1>
<!-- Current Status Section -->
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Current Status</h5>
<div id="status-info">
<p>Loading status...</p>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Garmin Connect Credentials</h5>
<form id="garmin-credentials-form">
<div class="mb-3">
<label for="garmin-username" class="form-label">Username</label>
<input type="text" class="form-control" id="garmin-username" name="username" required>
</div>
<div class="mb-3">
<label for="garmin-password" class="form-label">Password</label>
<input type="password" class="form-control" id="garmin-password" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="garmin-china" name="is_china">
<label class="form-check-label" for="garmin-china">Use China domain (garmin.cn)</label>
</div>
<button type="submit" class="btn btn-primary">Save Garmin Credentials</button>
</form>
<!-- Garmin Authentication Status -->
<div id="garmin-auth-status" class="mt-3">
<p>Loading Garmin authentication status...</p>
</div>
<!-- MFA Section -->
<div id="garmin-mfa-section" class="mt-3" style="display: none;">
<h6>Multi-Factor Authentication (MFA)</h6>
<div class="mb-3">
<label for="mfa-code" class="form-label">Enter Verification Code</label>
<input type="text" class="form-control" id="mfa-code" placeholder="Enter code from your authenticator app or SMS">
</div>
<button type="button" class="btn btn-primary" id="submit-mfa-btn">Submit Verification Code</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Fitbit API Credentials</h5>
<form id="fitbit-credentials-form">
<div class="mb-3">
<label for="fitbit-client-id" class="form-label">Client ID</label>
<input type="text" class="form-control" id="fitbit-client-id" name="client_id" required>
</div>
<div class="mb-3">
<label for="fitbit-client-secret" class="form-label">Client Secret</label>
<input type="password" class="form-control" id="fitbit-client-secret" name="client_secret" required>
</div>
<button type="submit" class="btn btn-primary">Save Fitbit Credentials</button>
</form>
<div class="mt-3">
<div id="auth-url-container" style="display: none;">
<p>After saving credentials, click the link below to authorize:</p>
<a id="auth-link" class="btn btn-secondary" href="#" target="_blank">Authorize with Fitbit</a>
</div>
</div>
<!-- Fitbit Authentication Status -->
<div id="fitbit-auth-status" class="mt-3">
<p>Loading Fitbit authentication status...</p>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Complete Fitbit OAuth Flow</h5>
<form id="fitbit-callback-form">
<div class="mb-3">
<label for="callback-url" class="form-label">Paste full callback URL from browser</label>
<input type="url" class="form-control" id="callback-url" name="callback_url" required>
</div>
<button type="submit" class="btn btn-success">Complete OAuth Flow</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load initial status information
loadStatusInfo();
// Setup form event listeners
document.getElementById('garmin-credentials-form').addEventListener('submit', saveGarminCredentials);
document.getElementById('fitbit-credentials-form').addEventListener('submit', saveFitbitCredentials);
document.getElementById('fitbit-callback-form').addEventListener('submit', completeFitbitAuth);
});
async function loadStatusInfo() {
try {
// Get general status
const statusResponse = await fetch('/api/status');
const statusData = await statusResponse.json();
// Update status info
const statusContainer = document.getElementById('status-info');
statusContainer.innerHTML = `
<div class="row">
<div class="col-md-4">
<p><strong>Total Weight Records:</strong> ${statusData.total_weight_records}</p>
<p><strong>Synced to Garmin:</strong> ${statusData.synced_weight_records}</p>
</div>
<div class="col-md-4">
<p><strong>Unsynced Records:</strong> ${statusData.unsynced_weight_records}</p>
<p><strong>Total Activities:</strong> ${statusData.total_activities}</p>
</div>
<div class="col-md-4">
<p><strong>Downloaded Activities:</strong> ${statusData.downloaded_activities}</p>
</div>
</div>
`;
// Get authentication status from a new API endpoint
const authStatusResponse = await fetch('/api/setup/auth-status');
if (authStatusResponse.ok) {
const authData = await authStatusResponse.json();
// Update Garmin auth status
const garminStatusContainer = document.getElementById('garmin-auth-status');
if (authData.garmin) {
garminStatusContainer.innerHTML = `
<div class="alert ${authData.garmin.authenticated ? 'alert-success' : 'alert-warning'}">
<h6>Garmin Authentication Status</h6>
<p><strong>Username:</strong> ${authData.garmin.username || 'Not set'}</p>
<p><strong>Authenticated:</strong> ${authData.garmin.authenticated ? 'Yes' : 'No'}</p>
${authData.garmin.token_expires_at ? `<p><strong>Token Expires:</strong> ${new Date(authData.garmin.token_expires_at).toLocaleString()}</p>` : ''}
${authData.garmin.last_login ? `<p><strong>Last Login:</strong> ${new Date(authData.garmin.last_login).toLocaleString()}</p>` : ''}
<p><strong>Domain:</strong> ${authData.garmin.is_china ? 'garmin.cn' : 'garmin.com'}</p>
</div>
`;
}
// Update Fitbit auth status
const fitbitStatusContainer = document.getElementById('fitbit-auth-status');
if (authData.fitbit) {
fitbitStatusContainer.innerHTML = `
<div class="alert ${authData.fitbit.authenticated ? 'alert-success' : 'alert-warning'}">
<h6>Fitbit Authentication Status</h6>
<p><strong>Client ID:</strong> ${authData.fitbit.client_id ? authData.fitbit.client_id.substring(0, 10) + '...' : 'Not set'}</p>
<p><strong>Authenticated:</strong> ${authData.fitbit.authenticated ? 'Yes' : 'No'}</p>
${authData.fitbit.token_expires_at ? `<p><strong>Token Expires:</strong> ${new Date(authData.fitbit.token_expires_at).toLocaleString()}</p>` : ''}
${authData.fitbit.last_login ? `<p><strong>Last Login:</strong> ${new Date(authData.fitbit.last_login).toLocaleString()}</p>` : ''}
</div>
`;
}
}
} catch (error) {
console.error('Error loading status info:', error);
}
}
async function saveGarminCredentials(event) {
event.preventDefault();
const formData = new FormData(event.target);
const credentials = {
username: formData.get('username'),
password: formData.get('password'),
is_china: formData.get('is_china') === 'on' || formData.get('is_china') === 'true'
};
try {
const response = await fetch('/api/setup/garmin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
const data = await response.json();
if (data.status === 'mfa_required') {
// Show MFA section if MFA is required and store session ID
document.getElementById('garmin-mfa-section').style.display = 'block';
// Store the session ID for later use when submitting the MFA code
window.garmin_mfa_session_id = data.session_id;
alert('Multi-factor authentication required. Please enter the verification code sent to your device.');
} else {
alert(data.message || 'Garmin credentials saved successfully');
// Refresh status after saving
loadStatusInfo();
// Hide MFA section if showing
document.getElementById('garmin-mfa-section').style.display = 'none';
}
} catch (error) {
console.error('Error saving Garmin credentials:', error);
alert('Error saving Garmin credentials: ' + error.message);
}
}
async function saveFitbitCredentials(event) {
event.preventDefault();
const formData = new FormData(event.target);
const credentials = {
client_id: formData.get('client_id'),
client_secret: formData.get('client_secret')
};
try {
const response = await fetch('/api/setup/fitbit', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
const data = await response.json();
alert(data.message || 'Fitbit credentials saved successfully');
// Show the authorization link
const authLink = document.getElementById('auth-link');
authLink.href = data.auth_url;
document.getElementById('auth-url-container').style.display = 'block';
} catch (error) {
console.error('Error saving Fitbit credentials:', error);
alert('Error saving Fitbit credentials: ' + error.message);
}
}
async function completeFitbitAuth(event) {
event.preventDefault();
const formData = new FormData(event.target);
const callbackData = {
callback_url: formData.get('callback_url')
};
try {
const response = await fetch('/api/setup/fitbit/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(callbackData)
});
const data = await response.json();
alert(data.message || 'Fitbit OAuth flow completed successfully');
// Refresh status after completing OAuth
loadStatusInfo();
} catch (error) {
console.error('Error completing Fitbit OAuth:', error);
alert('Error completing Fitbit OAuth: ' + error.message);
}
}
// Handle MFA submission
document.getElementById('submit-mfa-btn').addEventListener('click', submitMFA);
async function submitMFA() {
const mfaCode = document.getElementById('mfa-code').value.trim();
if (!mfaCode) {
alert('Please enter the verification code');
return;
}
try {
const response = await fetch('/api/setup/garmin/mfa', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
verification_code: mfaCode,
session_id: window.garmin_mfa_session_id
})
});
const data = await response.json();
if (response.ok) {
alert(data.message || 'MFA verification successful');
document.getElementById('garmin-mfa-section').style.display = 'none';
// Clear the MFA code field
document.getElementById('mfa-code').value = '';
// Refresh status after MFA
loadStatusInfo();
} else {
alert('MFA verification failed: ' + data.message);
}
} catch (error) {
console.error('Error submitting MFA code:', error);
alert('Error submitting MFA code: ' + error.message);
}
}
</script>
</body>
</html>