mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 12:48:47 +01:00
Initial project scaffolding for health tracker app
Set up backend and frontend structure for a health and fitness tracker using Python (FastAPI, SQLModel, DSPy) and React. Includes Docker and Compose configs, authentication, nutrition AI module, health/nutrition/user endpoints, database models, and basic frontend with routing and context. Enables tracking nutrition, health metrics, and user management, with architecture ready for future mobile and cloud deployment.
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/ai/__init__.py
Normal file
0
backend/app/ai/__init__.py
Normal file
25
backend/app/ai/nutrition.py
Normal file
25
backend/app/ai/nutrition.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import dspy
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class NutritionalInfo(BaseModel):
|
||||
name: str
|
||||
calories: float
|
||||
protein: float
|
||||
carbs: float
|
||||
fats: float
|
||||
micros: dict | None = None
|
||||
|
||||
class ExtractNutrition(dspy.Signature):
|
||||
"""Extract nutritional information from a food description."""
|
||||
description: str = dspy.InputField(desc="Description of the food or meal")
|
||||
nutritional_info: NutritionalInfo = dspy.OutputField(desc="Nutritional information as a structured object")
|
||||
|
||||
class NutritionModule(dspy.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.extract = dspy.ChainOfThought(ExtractNutrition)
|
||||
|
||||
def forward(self, description: str):
|
||||
return self.extract(description=description)
|
||||
|
||||
nutrition_module = NutritionModule()
|
||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
43
backend/app/api/deps.py
Normal file
43
backend/app/api/deps.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from typing import Generator
|
||||
from sqlmodel import Session
|
||||
from app.db import engine
|
||||
|
||||
from typing import Generator, Annotated
|
||||
from sqlmodel import Session, select
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.db import engine
|
||||
from app.models.user import User
|
||||
from app.core import security
|
||||
from app.config import settings
|
||||
from app.schemas.token import TokenPayload
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token")
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
SessionDep = Annotated[Session, Depends(get_session)]
|
||||
TokenDep = Annotated[str, Depends(oauth2_scheme)]
|
||||
|
||||
def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
||||
)
|
||||
token_data = TokenPayload(**payload)
|
||||
except (JWTError, ValidationError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
user = session.get(User, int(token_data.sub))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
8
backend/app/api/v1/api.py
Normal file
8
backend/app/api/v1/api.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import users, login, nutrition, health
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, tags=["login"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(nutrition.router, prefix="/nutrition", tags=["nutrition"])
|
||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
0
backend/app/api/v1/endpoints/__init__.py
Normal file
0
backend/app/api/v1/endpoints/__init__.py
Normal file
35
backend/app/api/v1/endpoints/health.py
Normal file
35
backend/app/api/v1/endpoints/health.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import Any, List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
from app.api import deps
|
||||
from app.models.health import HealthMetric
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class HealthMetricCreate(BaseModel):
|
||||
metric_type: str
|
||||
value: float
|
||||
unit: str
|
||||
user_id: int # TODO: remove when auth is fully integrated
|
||||
|
||||
@router.post("/", response_model=HealthMetric)
|
||||
def create_metric(
|
||||
*,
|
||||
session: Session = Depends(deps.get_session),
|
||||
metric_in: HealthMetricCreate,
|
||||
) -> Any:
|
||||
metric = HealthMetric(metric_type=metric_in.metric_type, value=metric_in.value, unit=metric_in.unit, user_id=metric_in.user_id)
|
||||
session.add(metric)
|
||||
session.commit()
|
||||
session.refresh(metric)
|
||||
return metric
|
||||
|
||||
@router.get("/{user_id}", response_model=List[HealthMetric])
|
||||
def read_metrics(
|
||||
user_id: int,
|
||||
session: Session = Depends(deps.get_session),
|
||||
) -> Any:
|
||||
statement = select(HealthMetric).where(HealthMetric.user_id == user_id)
|
||||
metrics = session.exec(statement).all()
|
||||
return metrics
|
||||
35
backend/app/api/v1/endpoints/login.py
Normal file
35
backend/app/api/v1/endpoints/login.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api import deps
|
||||
from app.core import security
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.token import Token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/login/access-token", response_model=Token)
|
||||
def login_access_token(
|
||||
session: Session = Depends(deps.get_session),
|
||||
form_data: OAuth2PasswordRequestForm = Depends()
|
||||
) -> Any:
|
||||
"""
|
||||
OAuth2 compatible token login, get an access token for future requests
|
||||
"""
|
||||
statement = select(User).where(User.email == form_data.username)
|
||||
user = session.exec(statement).first()
|
||||
|
||||
if not user or not security.verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Incorrect email or password")
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return {
|
||||
"access_token": security.create_access_token(
|
||||
user.id, expires_delta=access_token_expires
|
||||
),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
54
backend/app/api/v1/endpoints/nutrition.py
Normal file
54
backend/app/api/v1/endpoints/nutrition.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session
|
||||
from app.api import deps
|
||||
from app.ai.nutrition import nutrition_module, NutritionalInfo
|
||||
from app.core.security import create_access_token # Just ensuring we have auth imports if needed later
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
description: str
|
||||
|
||||
from app.models.food import FoodLog, FoodItem
|
||||
from app.api.deps import get_session
|
||||
from app.core.security import get_password_hash # Not needed
|
||||
from app.config import settings
|
||||
|
||||
@router.post("/analyze", response_model=NutritionalInfo)
|
||||
def analyze_food(
|
||||
request: AnalyzeRequest,
|
||||
) -> Any:
|
||||
"""
|
||||
Analyze food description and return nutritional info using DSPy.
|
||||
"""
|
||||
try:
|
||||
result = nutrition_module(description=request.description)
|
||||
return result.nutritional_info
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/log", response_model=FoodLog)
|
||||
def log_food(
|
||||
*,
|
||||
session: Session = Depends(deps.get_session),
|
||||
nutrition_info: NutritionalInfo,
|
||||
current_user: deps.CurrentUser,
|
||||
) -> Any:
|
||||
"""
|
||||
Save food log to database.
|
||||
"""
|
||||
food_log = FoodLog(
|
||||
user_id=current_user.id,
|
||||
name=nutrition_info.name,
|
||||
calories=nutrition_info.calories,
|
||||
protein=nutrition_info.protein,
|
||||
carbs=nutrition_info.carbs,
|
||||
fats=nutrition_info.fats,
|
||||
)
|
||||
session.add(food_log)
|
||||
session.commit()
|
||||
session.refresh(food_log)
|
||||
return food_log
|
||||
36
backend/app/api/v1/endpoints/users.py
Normal file
36
backend/app/api/v1/endpoints/users.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api import deps
|
||||
from app.core import security
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=UserRead)
|
||||
def create_user(
|
||||
*,
|
||||
session: Session = Depends(deps.get_session),
|
||||
user_in: UserCreate,
|
||||
) -> Any:
|
||||
"""
|
||||
Create new user.
|
||||
"""
|
||||
user = session.exec(select(User).where(User.email == user_in.email)).first()
|
||||
if user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The user with this email already exists in the system",
|
||||
)
|
||||
|
||||
user = User(
|
||||
email=user_in.email,
|
||||
username=user_in.username,
|
||||
password_hash=security.get_password_hash(user_in.password),
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
13
backend/app/config.py
Normal file
13
backend/app/config.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
OPENAI_API_KEY: str | None = None
|
||||
SECRET_KEY: str = "changethis"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
|
||||
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
7
backend/app/core/ai_config.py
Normal file
7
backend/app/core/ai_config.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import dspy
|
||||
from app.config import settings
|
||||
|
||||
def configure_dspy():
|
||||
if settings.OPENAI_API_KEY:
|
||||
lm = dspy.LM("openai/gpt-4o-mini", api_key=settings.OPENAI_API_KEY)
|
||||
dspy.configure(lm=lm)
|
||||
24
backend/app/core/security.py
Normal file
24
backend/app/core/security.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Union
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str:
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
14
backend/app/db.py
Normal file
14
backend/app/db.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlmodel import SQLModel, create_engine, Session, text
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL)
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
def init_db():
|
||||
with Session(engine) as session:
|
||||
session.exec(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
session.commit()
|
||||
SQLModel.metadata.create_all(engine)
|
||||
33
backend/app/main.py
Normal file
33
backend/app/main.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from fastapi import FastAPI
|
||||
from app.api.v1.api import api_router
|
||||
from app.db import init_db
|
||||
from app.core.ai_config import configure_dspy
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
configure_dspy()
|
||||
yield
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI(title="Healthy Fit API", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:5174", "http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Welcome to Healthy Fit API"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
27
backend/app/models/food.py
Normal file
27
backend/app/models/food.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict
|
||||
from sqlmodel import Field, SQLModel, JSON
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import Column
|
||||
|
||||
class FoodItem(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
calories: float
|
||||
protein: float
|
||||
carbs: float
|
||||
fats: float
|
||||
micros: Dict = Field(default={}, sa_column=Column(JSON))
|
||||
embedding: List[float] = Field(sa_column=Column(Vector(1536))) # OpenAI embedding size
|
||||
|
||||
class FoodLog(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
food_item_id: Optional[int] = Field(default=None, foreign_key="fooditem.id")
|
||||
name: str # In case no food item is linked or custom entry
|
||||
calories: float
|
||||
protein: float
|
||||
carbs: float
|
||||
fats: float
|
||||
image_url: Optional[str] = None
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
19
backend/app/models/health.py
Normal file
19
backend/app/models/health.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
class HealthMetric(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
metric_type: str = Field(index=True) # e.g., "weight", "cholesterol", "testosterone"
|
||||
value: float
|
||||
unit: str
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class HealthGoal(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
goal_type: str # e.g., "lose_weight", "gain_muscle"
|
||||
target_value: float
|
||||
target_date: Optional[datetime] = None
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
10
backend/app/models/user.py
Normal file
10
backend/app/models/user.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
username: str = Field(index=True, unique=True)
|
||||
email: str = Field(index=True, unique=True)
|
||||
password_hash: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
9
backend/app/schemas/token.py
Normal file
9
backend/app/schemas/token.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from typing import Optional
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
class Token(SQLModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
class TokenPayload(SQLModel):
|
||||
sub: Optional[str] = None
|
||||
17
backend/app/schemas/user.py
Normal file
17
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from typing import Optional
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
class UserBase(SQLModel):
|
||||
email: str
|
||||
username: str
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
class UserRead(UserBase):
|
||||
id: int
|
||||
|
||||
class UserUpdate(SQLModel):
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
Reference in New Issue
Block a user