change to TUI

This commit is contained in:
2025-09-12 09:08:10 -07:00
parent 7c7dcb5b10
commit e0e70f6508
165 changed files with 3438 additions and 16154 deletions

View File

@@ -1,72 +0,0 @@
# Multi-stage build for container-first development
FROM python:3.11-slim-bullseye AS builder
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Install system dependencies for building
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libpq-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Runtime stage
FROM python:3.11-slim-bullseye AS runtime
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Install runtime system dependencies only
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq5 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy installed packages from builder stage
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy application code
COPY . .
# Create entrypoint script for migration handling
RUN echo '#!/bin/bash\n\
set -e\n\
\n\
# Run database migrations synchronously\n\
echo "Running database migrations..."\n\
python -m alembic upgrade head\n\
\n\
# Verify migration success\n\
echo "Verifying migration status..."\n\
python -m alembic current\n\
\n\
# Start the application\n\
echo "Starting application..."\n\
exec "$@"' > /app/entrypoint.sh && \
chmod +x /app/entrypoint.sh
# Create non-root user and logs directory
RUN useradd -m appuser && \
mkdir -p /app/logs && \
chown -R appuser:appuser /app
USER appuser
# Expose application port
EXPOSE 8000
# Use entrypoint for migration automation
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,6 +1,6 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://postgres:password@db:5432/cycling
sqlalchemy.url = sqlite+aiosqlite:///data/cycling_coach.db
[loggers]
keys = root

View File

@@ -1,16 +1,21 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from alembic import context
import sys
import os
from pathlib import Path
# Add app directory to path
sys.path.append(os.getcwd())
# Add backend directory to path
backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
# Import base and models
from app.models.base import Base
from app.config import settings
from backend.app.models.base import Base
from backend.app.config import settings
# Import all models to ensure they're registered
from backend.app.models import *
config = context.config
fileConfig(config.config_file_name)
@@ -19,12 +24,13 @@ target_metadata = Base.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
url = settings.DATABASE_URL
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True, # Important for SQLite
)
with context.begin_transaction():
@@ -32,21 +38,28 @@ def run_migrations_offline():
async def run_migrations_online():
"""Run migrations in 'online' mode."""
connectable = AsyncEngine(
engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
future=True,
url=settings.DATABASE_URL,
)
# Ensure data directory exists
data_dir = Path("data")
data_dir.mkdir(exist_ok=True)
connectable = create_async_engine(
settings.DATABASE_URL,
poolclass=pool.NullPool,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True, # Important for SQLite ALTER TABLE support
)
with context.begin_transaction():
context.run_migrations()

Binary file not shown.

Binary file not shown.

View File

@@ -1,11 +1,20 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
DATABASE_URL: str
GPX_STORAGE_PATH: str
AI_MODEL: str = "openrouter/auto"
API_KEY: str
# Database settings
DATABASE_URL: str = "sqlite+aiosqlite:///data/cycling_coach.db"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# File storage settings
GPX_STORAGE_PATH: str = "data/gpx"
# AI settings
AI_MODEL: str = "deepseek/deepseek-r1"
OPENROUTER_API_KEY: str = ""
# Garmin settings
GARMIN_USERNAME: str = ""
GARMIN_PASSWORD: str = ""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
settings = Settings()

View File

@@ -1,10 +1,19 @@
import os
from pathlib import Path
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:password@db:5432/cycling")
# Use SQLite database in data directory
DATA_DIR = Path("data")
DATABASE_PATH = DATA_DIR / "cycling_coach.db"
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite+aiosqlite:///{DATABASE_PATH}")
engine = create_async_engine(
DATABASE_URL,
echo=False, # Set to True for SQL debugging
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
)
engine = create_async_engine(DATABASE_URL, echo=True)
AsyncSessionLocal = sessionmaker(
bind=engine,
class_=AsyncSession,
@@ -15,4 +24,19 @@ Base = declarative_base()
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session
yield session
async def init_db():
"""Initialize the database by creating all tables."""
# Ensure data directory exists
DATA_DIR.mkdir(exist_ok=True)
# Import all models to ensure they are registered
from .models import (
user, rule, plan, plan_rule, workout,
analysis, route, section, garmin_sync_log, prompt
)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -1,7 +1,7 @@
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.ai_service import AIService
from backend.app.database import get_db
from backend.app.services.ai_service import AIService
from typing import AsyncGenerator

View File

@@ -3,7 +3,7 @@ from .route import Route
from .section import Section
from .rule import Rule
from .plan import Plan
from .plan_rule import PlanRule
from .plan_rule import plan_rules
from .user import User
from .workout import Workout
from .analysis import Analysis

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, ForeignKey, JSON, Boolean, DateTime, func
from datetime import datetime
from sqlalchemy import Column, Integer, String, ForeignKey, JSON, Boolean, DateTime
from sqlalchemy.orm import relationship
from .base import BaseModel
@@ -13,7 +14,7 @@ class Analysis(BaseModel):
suggestions = Column(JSON)
approved = Column(Boolean, default=False)
created_plan_id = Column(Integer, ForeignKey('plans.id'))
approved_at = Column(DateTime(timezone=True), server_default=func.now())
approved_at = Column(DateTime, default=datetime.utcnow) # Changed from server_default=func.now()
# Relationships
workout = relationship("Workout", back_populates="analyses")

View File

@@ -1,15 +1,13 @@
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime
from sqlalchemy import Column, Integer, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
Base = declarative_base()
class BaseModel(Base):
__abstract__ = True
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
id = Column(Integer, primary_key=True, autoincrement=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -1,12 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import Column, Integer, ForeignKey, JSON
from sqlalchemy.orm import relationship
from .base import BaseModel
class Plan(BaseModel):
__tablename__ = "plans"
jsonb_plan = Column(JSONB, nullable=False)
jsonb_plan = Column(JSON, nullable=False) # Changed from JSONB to JSON for SQLite compatibility
version = Column(Integer, nullable=False)
parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True)

View File

@@ -1,12 +1,9 @@
from sqlalchemy import Column, Integer, ForeignKey
from sqlalchemy.orm import relationship
from .base import BaseModel
from sqlalchemy import Column, Integer, ForeignKey, Table
from .base import Base
class PlanRule(BaseModel):
__tablename__ = "plan_rules"
plan_id = Column(Integer, ForeignKey('plans.id'), primary_key=True)
rule_id = Column(Integer, ForeignKey('rules.id'), primary_key=True)
plan = relationship("Plan", back_populates="rules")
rule = relationship("Rule", back_populates="plans")
# Association table for many-to-many relationship between plans and rules
plan_rules = Table(
'plan_rules', Base.metadata,
Column('plan_id', Integer, ForeignKey('plans.id'), primary_key=True),
Column('rule_id', Integer, ForeignKey('rules.id'), primary_key=True)
)

View File

@@ -1,7 +1,12 @@
from .base import BaseModel
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship
from .base import BaseModel
class User(BaseModel):
__tablename__ = "users"
plans = relationship("Plan", back_populates="user")
username = Column(String(100), nullable=False, unique=True)
email = Column(String(255), nullable=True)
# Note: Relationship removed as Plan model doesn't have user_id field
# plans = relationship("Plan", back_populates="user")

View File

@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.workout import Workout
from app.models.plan import Plan
from app.models.garmin_sync_log import GarminSyncLog
from backend.app.database import get_db
from backend.app.models.workout import Workout
from backend.app.models.plan import Plan
from backend.app.models.garmin_sync_log import GarminSyncLog
from sqlalchemy import select, desc
from datetime import datetime, timedelta

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import FileResponse
from app.services.export_service import ExportService
from backend.app.services.export_service import ExportService
from pathlib import Path
import logging

View File

@@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import verify_api_key
from app.services.workout_sync import WorkoutSyncService
from app.database import get_db
from backend.app.dependencies import verify_api_key
from backend.app.services.workout_sync import WorkoutSyncService
from backend.app.database import get_db
router = APIRouter(dependencies=[Depends(verify_api_key)])

View File

@@ -1,9 +1,9 @@
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.gpx import parse_gpx, store_gpx_file
from app.schemas.gpx import RouteCreate, Route as RouteSchema
from app.models import Route
from backend.app.database import get_db
from backend.app.services.gpx import parse_gpx, store_gpx_file
from backend.app.schemas.gpx import RouteCreate, Route as RouteSchema
from backend.app.models import Route
import os
router = APIRouter(prefix="/gpx", tags=["GPX Routes"])

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from fastapi.responses import PlainTextResponse, JSONResponse
from app.services.health_monitor import HealthMonitor
from backend.app.services.health_monitor import HealthMonitor
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Gauge
from pathlib import Path
import json

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi.responses import JSONResponse
from app.services.import_service import ImportService
from backend.app.services.import_service import ImportService
import logging
from typing import Optional

View File

@@ -1,12 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.plan import Plan as PlanModel
from app.models.rule import Rule
from app.schemas.plan import PlanCreate, Plan as PlanSchema, PlanGenerationRequest, PlanGenerationResponse
from app.dependencies import get_ai_service
from app.services.ai_service import AIService
from backend.app.database import get_db
from backend.app.models.plan import Plan as PlanModel
from backend.app.models.rule import Rule
from backend.app.schemas.plan import PlanCreate, Plan as PlanSchema, PlanGenerationRequest, PlanGenerationResponse
from backend.app.dependencies import get_ai_service
from backend.app.services.ai_service import AIService
from uuid import UUID, uuid4
from datetime import datetime
from typing import List

View File

@@ -3,10 +3,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models.prompt import Prompt
from app.schemas.prompt import Prompt as PromptSchema, PromptCreate, PromptUpdate
from app.services.prompt_manager import PromptManager
from backend.app.database import get_db
from backend.app.models.prompt import Prompt
from backend.app.schemas.prompt import Prompt as PromptSchema, PromptCreate, PromptUpdate
from backend.app.services.prompt_manager import PromptManager
router = APIRouter()

View File

@@ -1,11 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.rule import Rule
from app.schemas.rule import RuleCreate, Rule as RuleSchema, NaturalLanguageRuleRequest, ParsedRuleResponse
from app.dependencies import get_ai_service
from app.services.ai_service import AIService
from backend.app.database import get_db
from backend.app.models.rule import Rule
from backend.app.schemas.rule import RuleCreate, Rule as RuleSchema, NaturalLanguageRuleRequest, ParsedRuleResponse
from backend.app.dependencies import get_ai_service
from backend.app.services.ai_service import AIService
from uuid import UUID
from typing import List

View File

@@ -3,17 +3,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models.workout import Workout
from app.models.analysis import Analysis
from app.models.garmin_sync_log import GarminSyncLog
from app.models.plan import Plan
from app.schemas.workout import Workout as WorkoutSchema, WorkoutSyncStatus, WorkoutMetric
from app.schemas.analysis import Analysis as AnalysisSchema
from app.schemas.plan import Plan as PlanSchema
from app.services.workout_sync import WorkoutSyncService
from app.services.ai_service import AIService
from app.services.plan_evolution import PlanEvolutionService
from backend.app.database import get_db
from backend.app.models.workout import Workout
from backend.app.models.analysis import Analysis
from backend.app.models.garmin_sync_log import GarminSyncLog
from backend.app.models.plan import Plan
from backend.app.schemas.workout import Workout as WorkoutSchema, WorkoutSyncStatus, WorkoutMetric
from backend.app.schemas.analysis import Analysis as AnalysisSchema
from backend.app.schemas.plan import Plan as PlanSchema
from backend.app.services.workout_sync import WorkoutSyncService
from backend.app.services.ai_service import AIService
from backend.app.services.plan_evolution import PlanEvolutionService
router = APIRouter()

View File

@@ -3,8 +3,8 @@ import asyncio
from typing import Dict, Any, List, Optional
import httpx
import json
from app.services.prompt_manager import PromptManager
from app.models.workout import Workout
from backend.app.services.prompt_manager import PromptManager
from backend.app.models.workout import Workout
import logging
logger = logging.getLogger(__name__)

View File

@@ -2,8 +2,8 @@ import json
from pathlib import Path
from datetime import datetime
import zipfile
from app.database import SessionLocal
from app.models import Route, Rule, Plan
from backend.app.database import SessionLocal
from backend.app.models import Route, Rule, Plan
import tempfile
import logging
import shutil

View File

@@ -3,7 +3,7 @@ import uuid
import logging
from fastapi import UploadFile, HTTPException
import gpxpy
from app.config import settings
from backend.app.config import settings
logger = logging.getLogger(__name__)

View File

@@ -3,10 +3,10 @@ from datetime import datetime
import logging
from typing import Dict, Any
from sqlalchemy import text
from app.database import get_db
from app.models.garmin_sync_log import GarminSyncLog, SyncStatus
from backend.app.database import get_db
from backend.app.models.garmin_sync_log import GarminSyncLog, SyncStatus
import requests
from app.config import settings
from backend.app.config import settings
logger = logging.getLogger(__name__)
@@ -43,12 +43,12 @@ class HealthMonitor:
def _get_sync_queue_size(self) -> int:
"""Get number of pending sync operations"""
from app.models.garmin_sync_log import GarminSyncLog, SyncStatus
from backend.app.models.garmin_sync_log import GarminSyncLog, SyncStatus
return GarminSyncLog.query.filter_by(status=SyncStatus.PENDING).count()
def _count_pending_analyses(self) -> int:
"""Count workouts needing analysis"""
from app.models.workout import Workout
from backend.app.models.workout import Workout
return Workout.query.filter_by(analysis_status='pending').count()
def _check_database(self) -> str:

View File

@@ -3,8 +3,8 @@ import zipfile
from pathlib import Path
import tempfile
from datetime import datetime
from app.database import SessionLocal
from app.models import Route, Rule, Plan
from backend.app.database import SessionLocal
from backend.app.models import Route, Rule, Plan
import shutil
import logging
from sqlalchemy import and_

View File

@@ -1,8 +1,8 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.services.ai_service import AIService
from app.models.analysis import Analysis
from app.models.plan import Plan
from backend.app.services.ai_service import AIService
from backend.app.models.analysis import Analysis
from backend.app.models.plan import Plan
import logging
logger = logging.getLogger(__name__)

View File

@@ -1,6 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func
from app.models.prompt import Prompt
from backend.app.models.prompt import Prompt
import logging
logger = logging.getLogger(__name__)

View File

@@ -1,9 +1,9 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.services.garmin import GarminService, GarminAPIError, GarminAuthError
from app.models.workout import Workout
from app.models.garmin_sync_log import GarminSyncLog
from app.models.garmin_sync_log import GarminSyncLog
from backend.app.services.garmin import GarminService, GarminAPIError, GarminAuthError
from backend.app.models.workout import Workout
from backend.app.models.garmin_sync_log import GarminSyncLog
from backend.app.models.garmin_sync_log import GarminSyncLog
from datetime import datetime, timedelta
import logging
from typing import Dict, Any

View File

@@ -16,7 +16,7 @@ from typing import Optional
backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
from app.database import get_database_url
from backend.app.database import get_database_url
class DatabaseManager:
"""Handles database backup and restore operations."""

View File

@@ -18,7 +18,7 @@ from alembic import command
from alembic.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, text
from app.database import get_database_url
from backend.app.database import get_database_url
class MigrationChecker:
"""Validates migration compatibility and integrity."""

View File

@@ -17,7 +17,7 @@ from alembic import command
from alembic.migration import MigrationContext
from alembic.script import ScriptDirectory
import sqlalchemy as sa
from app.database import get_database_url
from backend.app.database import get_database_url
def get_alembic_config():
"""Get Alembic configuration."""

View File

@@ -1,7 +1,7 @@
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db, Base
from backend.app.main import app
from backend.app.database import get_db, Base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

View File

@@ -1,7 +1,7 @@
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from app.services.ai_service import AIService, AIServiceError
from app.models.workout import Workout
from backend.app.services.ai_service import AIService, AIServiceError
from backend.app.models.workout import Workout
import json
@pytest.mark.asyncio

View File

@@ -1,7 +1,7 @@
import pytest
from unittest.mock import AsyncMock, patch
from app.services.garmin import GarminService
from app.models.garmin_sync_log import GarminSyncStatus
from backend.app.services.garmin import GarminService
from backend.app.models.garmin_sync_log import GarminSyncStatus
from datetime import datetime, timedelta
@pytest.mark.asyncio

View File

@@ -1,8 +1,8 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from app.services.plan_evolution import PlanEvolutionService
from app.models.plan import Plan
from app.models.analysis import Analysis
from backend.app.services.plan_evolution import PlanEvolutionService
from backend.app.models.plan import Plan
from backend.app.models.analysis import Analysis
from datetime import datetime
@pytest.mark.asyncio

View File

@@ -1,8 +1,8 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from app.services.workout_sync import WorkoutSyncService
from app.models.workout import Workout
from app.models.garmin_sync_log import GarminSyncLog
from backend.app.services.workout_sync import WorkoutSyncService
from backend.app.models.workout import Workout
from backend.app.models.garmin_sync_log import GarminSyncLog
from datetime import datetime, timedelta
import asyncio