diff --git a/README.md b/README.md index e8acfba..8ccc1b6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,133 @@ -# FastAPI Application +# Calories Calculator API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A FastAPI-based API for tracking calories and nutritional information. This application allows users to register, track their food intake, and calculate their recommended calorie intake based on personal metrics. + +## Features + +- User registration and authentication +- Food database management +- Calorie tracking by meal type +- Daily and weekly calorie summaries +- BMR (Basal Metabolic Rate) and TDEE (Total Daily Energy Expenditure) calculations +- Personalized calorie recommendations based on goals + +## Getting Started + +### Prerequisites + +- Python 3.8+ +- SQLite + +### Installation + +1. Clone the repository: + +```bash +git clone +cd caloriescalculatorapi +``` + +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Run database migrations: + +```bash +alembic upgrade head +``` + +4. Start the application: + +```bash +uvicorn main:app --reload +``` + +The API will be available at http://localhost:8000. + +### API Documentation + +Once the application is running, you can access the interactive API documentation at: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## API Endpoints + +### Authentication + +- `POST /api/v1/auth/register` - Register a new user +- `POST /api/v1/auth/login` - Login and get access token +- `POST /api/v1/auth/test-token` - Test if the token is valid + +### Users + +- `GET /api/v1/users/me` - Get current user data +- `PUT /api/v1/users/me` - Update current user data + +### Foods + +- `GET /api/v1/foods` - List food items +- `POST /api/v1/foods` - Create a new food item +- `GET /api/v1/foods/{food_id}` - Get a specific food item +- `PUT /api/v1/foods/{food_id}` - Update a food item +- `DELETE /api/v1/foods/{food_id}` - Delete a food item +- `GET /api/v1/foods/my-foods` - List food items created by the current user + +### Calorie Entries + +- `GET /api/v1/calorie-entries` - List calorie entries +- `POST /api/v1/calorie-entries` - Create a new calorie entry +- `GET /api/v1/calorie-entries/{entry_id}` - Get a specific calorie entry +- `PUT /api/v1/calorie-entries/{entry_id}` - Update a calorie entry +- `DELETE /api/v1/calorie-entries/{entry_id}` - Delete a calorie entry +- `GET /api/v1/calorie-entries/daily-summary` - Get daily calorie summary +- `GET /api/v1/calorie-entries/weekly-summary` - Get weekly calorie summary + +### Calculator + +- `POST /api/v1/calculator/calculate` - Calculate recommended calories +- `GET /api/v1/calculator/calculate-from-profile` - Calculate recommended calories based on user profile + +## Project Structure + +``` +app/ +├── api/ # API endpoints +│ └── v1/ +│ ├── endpoints/ # API route handlers +│ └── api.py # API router configuration +├── core/ # Core functionality +│ ├── config.py # Application configuration +│ ├── deps.py # Dependencies +│ └── security.py # Security utilities +├── db/ # Database configuration +│ ├── base.py # SQLAlchemy Base class +│ └── session.py # Database session setup +├── models/ # SQLAlchemy models +├── schemas/ # Pydantic schemas +├── services/ # Business logic +└── utils/ # Utility functions +migrations/ # Alembic migrations +main.py # Application entry point +``` + +## Development + +### Running Tests + +```bash +pytest +``` + +### Running Linters + +```bash +ruff check . +``` + +## License + +This project is licensed under the MIT License. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..75005f9 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,84 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# 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 migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat migrations/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[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 + +# 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 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8aff352 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +""" +App package initialization +""" \ No newline at end of file diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..abd87f9 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,3 @@ +""" +API package initialization +""" \ No newline at end of file diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e130162 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1,3 @@ +""" +API v1 package initialization +""" \ No newline at end of file diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..1b9650c --- /dev/null +++ b/app/api/v1/api.py @@ -0,0 +1,13 @@ +""" +API router configuration +""" +from fastapi import APIRouter + +from app.api.v1.endpoints import auth, users, foods, calorie_entries, calculator + +api_router = APIRouter() +api_router.include_router(auth.router, prefix="/auth", tags=["authentication"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(foods.router, prefix="/foods", tags=["foods"]) +api_router.include_router(calorie_entries.router, prefix="/calorie-entries", tags=["calorie-entries"]) +api_router.include_router(calculator.router, prefix="/calculator", tags=["calculator"]) \ No newline at end of file diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..eb96897 --- /dev/null +++ b/app/api/v1/endpoints/__init__.py @@ -0,0 +1,3 @@ +""" +API v1 endpoints package initialization +""" \ No newline at end of file diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..ece5c4c --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -0,0 +1,76 @@ +""" +Authentication endpoints +""" +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app import models, schemas, services +from app.core import security +from app.core.config import settings +from app.core.deps import get_current_user +from app.db.session import get_db + +router = APIRouter() + + +@router.post("/login", response_model=schemas.Token) +def login_access_token( + db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = services.user.authenticate( + db, email_or_username=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException(status_code=400, detail="Incorrect email/username or password") + elif not services.user.is_active(user): + raise HTTPException(status_code=400, detail="Inactive user") + + 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", + } + + +@router.post("/test-token", response_model=schemas.User) +def test_token(current_user: models.User = Depends(get_current_user)) -> Any: + """ + Test access token + """ + return current_user + + +@router.post("/register", response_model=schemas.User) +def register_user( + *, + db: Session = Depends(get_db), + user_in: schemas.UserCreate, +) -> Any: + """ + Register a new user + """ + user = services.user.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="A user with this email already exists", + ) + + user = services.user.get_by_username(db, username=user_in.username) + if user: + raise HTTPException( + status_code=400, + detail="A user with this username already exists", + ) + + user = services.user.create(db, obj_in=user_in) + return user \ No newline at end of file diff --git a/app/api/v1/endpoints/calculator.py b/app/api/v1/endpoints/calculator.py new file mode 100644 index 0000000..afa2b0b --- /dev/null +++ b/app/api/v1/endpoints/calculator.py @@ -0,0 +1,78 @@ +""" +Calorie calculator endpoints +""" +from datetime import date +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app import models +from app.core.deps import get_current_active_user +from app.db.session import get_db +from app.utils.calories import ( + ActivityLevel, + WeightGoal, + calculate_recommended_calories +) + +router = APIRouter() + + +class CalorieCalculationRequest(BaseModel): + """Request model for calorie calculation""" + weight_kg: float + height_cm: float + birth_date: date + gender: str + activity_level: ActivityLevel + goal: WeightGoal + + +@router.post("/calculate", response_model=Dict[str, Any]) +def calculate_calories( + *, + calculation_in: CalorieCalculationRequest, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Calculate recommended daily calories based on personal data and goals. + """ + result = calculate_recommended_calories( + weight_kg=calculation_in.weight_kg, + height_cm=calculation_in.height_cm, + birth_date=calculation_in.birth_date, + gender=calculation_in.gender, + activity_level=calculation_in.activity_level, + goal=calculation_in.goal + ) + return result + + +@router.get("/calculate-from-profile", response_model=Dict[str, Any]) +def calculate_calories_from_profile( + *, + db: Session = Depends(get_db), + activity_level: ActivityLevel, + goal: WeightGoal, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Calculate recommended daily calories based on user profile data. + """ + if not current_user.weight_kg or not current_user.height_cm or not current_user.date_of_birth or not current_user.gender: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Profile data incomplete. Please update your profile with weight, height, date of birth, and gender." + ) + + result = calculate_recommended_calories( + weight_kg=current_user.weight_kg, + height_cm=current_user.height_cm, + birth_date=current_user.date_of_birth, + gender=current_user.gender, + activity_level=activity_level, + goal=goal + ) + return result \ No newline at end of file diff --git a/app/api/v1/endpoints/calorie_entries.py b/app/api/v1/endpoints/calorie_entries.py new file mode 100644 index 0000000..6f156fb --- /dev/null +++ b/app/api/v1/endpoints/calorie_entries.py @@ -0,0 +1,231 @@ +""" +Calorie entries endpoints +""" +from datetime import date +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app import models, schemas, services +from app.core.deps import get_current_active_user +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.CalorieEntryWithFood]) +def read_entries( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + meal_type: Optional[str] = None, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve calorie entries for the current user with optional filtering. + """ + entries = services.calorie_entry.get_multi_by_user( + db, + user_id=current_user.id, + skip=skip, + limit=limit, + start_date=start_date, + end_date=end_date, + meal_type=meal_type, + ) + + # Calculate calories for each entry + result = [] + for entry in entries: + calories = entry.quantity_g / 100 * entry.food.calories_per_100g + # Create a CalorieEntryWithFood object + entry_with_food = schemas.CalorieEntryWithFood( + id=entry.id, + user_id=entry.user_id, + food_id=entry.food_id, + quantity_g=entry.quantity_g, + meal_type=entry.meal_type, + notes=entry.notes, + consumed_at=entry.consumed_at, + created_at=entry.created_at, + updated_at=entry.updated_at, + food=schemas.Food.model_validate(entry.food), + calories=round(calories, 1), + ) + result.append(entry_with_food) + + return result + + +@router.post("/", response_model=schemas.CalorieEntry) +def create_entry( + *, + db: Session = Depends(get_db), + entry_in: schemas.CalorieEntryCreate, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Create new calorie entry. + """ + # Verify food exists + food = services.food.get(db=db, food_id=entry_in.food_id) + if not food: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Food item not found", + ) + + entry = services.calorie_entry.create( + db=db, obj_in=entry_in, user_id=current_user.id + ) + return entry + + +@router.get("/daily-summary", response_model=Dict[str, Any]) +def get_daily_summary( + date_: date = Query(None, description="Date to get summary for, defaults to today"), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Get calorie summary for a specific date. Defaults to today. + """ + if date_ is None: + date_ = date.today() + + summary = services.calorie_entry.get_daily_summary( + db, user_id=current_user.id, date_=date_ + ) + return summary + + +@router.get("/weekly-summary", response_model=List[Dict[str, Any]]) +def get_weekly_summary( + end_date: date = Query(None, description="End date for weekly summary, defaults to today"), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Get calorie summary for the last 7 days ending on the specified date. Defaults to today. + """ + if end_date is None: + end_date = date.today() + + summary = services.calorie_entry.get_weekly_summary( + db, user_id=current_user.id, end_date=end_date + ) + return summary + + +@router.get("/{entry_id}", response_model=schemas.CalorieEntryWithFood) +def read_entry( + *, + db: Session = Depends(get_db), + entry_id: int, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Get calorie entry by ID. + """ + entry = services.calorie_entry.get(db=db, entry_id=entry_id) + if not entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Calorie entry not found", + ) + + # Verify ownership + if entry.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + # Calculate calories + calories = entry.quantity_g / 100 * entry.food.calories_per_100g + + # Create a CalorieEntryWithFood object + entry_with_food = schemas.CalorieEntryWithFood( + id=entry.id, + user_id=entry.user_id, + food_id=entry.food_id, + quantity_g=entry.quantity_g, + meal_type=entry.meal_type, + notes=entry.notes, + consumed_at=entry.consumed_at, + created_at=entry.created_at, + updated_at=entry.updated_at, + food=schemas.Food.model_validate(entry.food), + calories=round(calories, 1), + ) + + return entry_with_food + + +@router.put("/{entry_id}", response_model=schemas.CalorieEntry) +def update_entry( + *, + db: Session = Depends(get_db), + entry_id: int, + entry_in: schemas.CalorieEntryUpdate, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Update a calorie entry. + """ + entry = services.calorie_entry.get(db=db, entry_id=entry_id) + if not entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Calorie entry not found", + ) + + # Verify ownership + if entry.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + # Verify food exists if changing food_id + if entry_in.food_id and entry_in.food_id != entry.food_id: + food = services.food.get(db=db, food_id=entry_in.food_id) + if not food: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Food item not found", + ) + + entry = services.calorie_entry.update(db=db, db_obj=entry, obj_in=entry_in) + return entry + + +@router.delete("/{entry_id}", response_model=schemas.CalorieEntry) +def delete_entry( + *, + db: Session = Depends(get_db), + entry_id: int, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Delete a calorie entry. + """ + entry = services.calorie_entry.get(db=db, entry_id=entry_id) + if not entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Calorie entry not found", + ) + + # Verify ownership + if entry.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + entry = services.calorie_entry.delete(db=db, entry_id=entry_id) + return entry \ No newline at end of file diff --git a/app/api/v1/endpoints/foods.py b/app/api/v1/endpoints/foods.py new file mode 100644 index 0000000..2cc0bd8 --- /dev/null +++ b/app/api/v1/endpoints/foods.py @@ -0,0 +1,165 @@ +""" +Food items endpoints +""" +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app import models, schemas, services +from app.core.deps import get_current_active_superuser, get_current_active_user +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Food]) +def read_foods( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + name: Optional[str] = None, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve food items. Supports filtering by name. + """ + foods = services.food.get_multi(db, skip=skip, limit=limit, name_filter=name) + return foods + + +@router.post("/", response_model=schemas.Food) +def create_food( + *, + db: Session = Depends(get_db), + food_in: schemas.FoodCreate, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Create new food item. + """ + # Set is_verified to False for user-created foods, unless user is superuser + if not current_user.is_superuser: + food_in.is_verified = False + + food = services.food.create( + db=db, obj_in=food_in, created_by_id=current_user.id + ) + return food + + +@router.get("/my-foods", response_model=List[schemas.Food]) +def read_user_foods( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve food items created by the current user. + """ + foods = services.food.get_multi_by_user( + db=db, user_id=current_user.id, skip=skip, limit=limit + ) + return foods + + +@router.get("/{food_id}", response_model=schemas.Food) +def read_food( + *, + db: Session = Depends(get_db), + food_id: int, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Get food item by ID. + """ + food = services.food.get(db=db, food_id=food_id) + if not food: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Food item not found", + ) + return food + + +@router.put("/{food_id}", response_model=schemas.Food) +def update_food( + *, + db: Session = Depends(get_db), + food_id: int, + food_in: schemas.FoodUpdate, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Update a food item. + """ + food = services.food.get(db=db, food_id=food_id) + if not food: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Food item not found", + ) + + # Only allow update if user is superuser or the creator of the food item + if not current_user.is_superuser and food.created_by_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + # Regular users cannot mark items as verified + if not current_user.is_superuser and food_in.is_verified: + food_in.is_verified = False + + food = services.food.update(db=db, db_obj=food, obj_in=food_in) + return food + + +@router.delete("/{food_id}", response_model=schemas.Food) +def delete_food( + *, + db: Session = Depends(get_db), + food_id: int, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Delete a food item. + """ + food = services.food.get(db=db, food_id=food_id) + if not food: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Food item not found", + ) + + # Only allow deletion if user is superuser or the creator of the food item + if not current_user.is_superuser and food.created_by_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + food = services.food.delete(db=db, food_id=food_id) + return food + + +@router.post("/verify/{food_id}", response_model=schemas.Food) +def verify_food( + *, + db: Session = Depends(get_db), + food_id: int, + current_user: models.User = Depends(get_current_active_superuser), +) -> Any: + """ + Verify a food item. Only superusers can verify food items. + """ + food = services.food.get(db=db, food_id=food_id) + if not food: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Food item not found", + ) + + food = services.food.update(db=db, db_obj=food, obj_in={"is_verified": True}) + return food \ No newline at end of file diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..59f031b --- /dev/null +++ b/app/api/v1/endpoints/users.py @@ -0,0 +1,131 @@ +""" +User management endpoints +""" +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app import models, schemas, services +from app.core.deps import get_current_active_superuser, get_current_active_user +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.User]) +def read_users( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(get_current_active_superuser), +) -> Any: + """ + Retrieve users. Only superusers can access this endpoint. + """ + users = services.user.get_multi(db, skip=skip, limit=limit) + return users + + +@router.get("/me", response_model=schemas.User) +def read_user_me( + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Get current user. + """ + return current_user + + +@router.put("/me", response_model=schemas.User) +def update_user_me( + *, + db: Session = Depends(get_db), + user_in: schemas.UserUpdate, + current_user: models.User = Depends(get_current_active_user), +) -> Any: + """ + Update own user. + """ + if user_in.username and user_in.username != current_user.username: + user = services.user.get_by_username(db, username=user_in.username) + if user: + raise HTTPException( + status_code=400, + detail="Username already registered", + ) + + if user_in.email and user_in.email != current_user.email: + user = services.user.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="Email already registered", + ) + + user = services.user.update(db, db_obj=current_user, obj_in=user_in) + return user + + +@router.get("/{user_id}", response_model=schemas.User) +def read_user( + user_id: int, + current_user: models.User = Depends(get_current_active_user), + db: Session = Depends(get_db), +) -> Any: + """ + Get a specific user by id. + """ + user = services.user.get(db, user_id=user_id) + if user == current_user: + return user + if not services.user.is_superuser(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", + ) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return user + + +@router.put("/{user_id}", response_model=schemas.User) +def update_user( + *, + db: Session = Depends(get_db), + user_id: int, + user_in: schemas.UserUpdate, + current_user: models.User = Depends(get_current_active_superuser), +) -> Any: + """ + Update a user. Only superusers can access this endpoint. + """ + user = services.user.get(db, user_id=user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + # Check if username or email already exists + if user_in.username and user_in.username != user.username: + existing_user = services.user.get_by_username(db, username=user_in.username) + if existing_user: + raise HTTPException( + status_code=400, + detail="Username already registered", + ) + + if user_in.email and user_in.email != user.email: + existing_user = services.user.get_by_email(db, email=user_in.email) + if existing_user: + raise HTTPException( + status_code=400, + detail="Email already registered", + ) + + user = services.user.update(db, db_obj=user, obj_in=user_in) + return user \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e374a0d --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,3 @@ +""" +Core package initialization +""" \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..411411d --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,40 @@ +""" +Application configuration settings +""" +from typing import List, Union + +from pydantic import AnyHttpUrl, EmailStr, validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application settings""" + API_V1_STR: str = "/api/v1" + SECRET_KEY: str = "development_secret_key_change_in_production" + # 60 minutes * 24 hours * 8 days = 8 days + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + + # CORS Configuration + BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] + + @validator("BACKEND_CORS_ORIGINS", pre=True) + def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]: + """Validate CORS origins""" + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, (list, str)): + return v + raise ValueError(v) + + # SQLite Database settings + SQLALCHEMY_DATABASE_URI: str = "sqlite:///app/storage/db/db.sqlite" + + # Superuser settings for initial setup + FIRST_SUPERUSER: EmailStr = "admin@example.com" + FIRST_SUPERUSER_USERNAME: str = "admin" + FIRST_SUPERUSER_PASSWORD: str = "admin" + + model_config = SettingsConfigDict(case_sensitive=True) + + +settings = Settings() \ No newline at end of file diff --git a/app/core/deps.py b/app/core/deps.py new file mode 100644 index 0000000..8698e29 --- /dev/null +++ b/app/core/deps.py @@ -0,0 +1,64 @@ +""" +Dependency utilities for the application +""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app import models, schemas +from app.core.config import settings +from app.db.session import get_db + +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/auth/login" +) + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) +) -> models.User: + """ + Get the current user from the JWT token + """ + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=["HS256"] + ) + token_data = schemas.TokenPayload(**payload) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + user = db.query(models.User).filter(models.User.id == token_data.sub).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +def get_current_active_user( + current_user: models.User = Depends(get_current_user), +) -> models.User: + """ + Get the current active user + """ + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +def get_current_active_superuser( + current_user: models.User = Depends(get_current_user), +) -> models.User: + """ + Get the current active superuser + """ + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", + ) + return current_user \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..b818e66 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,43 @@ +""" +Security utilities for authentication +""" +from datetime import datetime, timedelta +from typing import Any, Union + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def create_access_token( + subject: Union[str, Any], expires_delta: timedelta = None +) -> str: + """ + Create a JWT access token + """ + 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="HS256") + return encoded_jwt + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a password against a hash + """ + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """ + Hash a password + """ + return pwd_context.hash(password) \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..3d782a1 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,31 @@ +""" +Database Base models configuration +""" +from typing import Any + +from sqlalchemy import MetaData +from sqlalchemy.ext.declarative import as_declarative, declared_attr + + +convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + +metadata = MetaData(naming_convention=convention) + + +@as_declarative(metadata=metadata) +class Base: + """Base class for all database models""" + + id: Any + __name__: str + + # Generate __tablename__ automatically based on class name + @declared_attr + def __tablename__(cls) -> str: + return cls.__name__.lower() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..66edc29 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,30 @@ +""" +Database session configuration +""" +from pathlib import Path + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# Create the database directory if it doesn't exist +DB_DIR = Path("/app") / "storage" / "db" +DB_DIR.mkdir(parents=True, exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + """ + Dependency for getting the database session + """ + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..6d58c15 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,8 @@ +""" +Models package initialization +""" +from app.models.user import User +from app.models.food import Food +from app.models.calorie_entry import CalorieEntry + +__all__ = ["User", "Food", "CalorieEntry"] \ No newline at end of file diff --git a/app/models/calorie_entry.py b/app/models/calorie_entry.py new file mode 100644 index 0000000..88fbef0 --- /dev/null +++ b/app/models/calorie_entry.py @@ -0,0 +1,25 @@ +""" +CalorieEntry model for the application +""" +from datetime import datetime +from sqlalchemy import Column, String, Integer, DateTime, Float, ForeignKey +from sqlalchemy.orm import relationship + +from app.db.base import Base + + +class CalorieEntry(Base): + """CalorieEntry model for tracking user's calorie intake""" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False) + food_id = Column(Integer, ForeignKey("food.id"), nullable=False) + quantity_g = Column(Float, nullable=False) + meal_type = Column(String, nullable=True) # breakfast, lunch, dinner, snack + notes = Column(String, nullable=True) + consumed_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="calorie_entries") + food = relationship("Food", back_populates="calorie_entries") \ No newline at end of file diff --git a/app/models/food.py b/app/models/food.py new file mode 100644 index 0000000..57ec168 --- /dev/null +++ b/app/models/food.py @@ -0,0 +1,29 @@ +""" +Food model for the application +""" +from datetime import datetime +from sqlalchemy import Column, String, Integer, DateTime, Float, Boolean, ForeignKey +from sqlalchemy.orm import relationship + +from app.db.base import Base + + +class Food(Base): + """Food model for storing food items and their nutritional information""" + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + description = Column(String, nullable=True) + calories_per_100g = Column(Float, nullable=False) + protein_g = Column(Float, nullable=True) + carbs_g = Column(Float, nullable=True) + fat_g = Column(Float, nullable=True) + fiber_g = Column(Float, nullable=True) + is_verified = Column(Boolean, default=False) + # If created by user, store the user ID + created_by_id = Column(Integer, ForeignKey("user.id"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + created_by = relationship("User", backref="created_foods") + calorie_entries = relationship("CalorieEntry", back_populates="food") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..659986d --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,31 @@ +""" +User model for the application +""" +from datetime import datetime +from sqlalchemy import Column, String, Integer, DateTime, Float, Boolean +from sqlalchemy.orm import relationship + +from app.db.base import Base + + +class User(Base): + """User model for storing user related data""" + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + username = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + first_name = Column(String, nullable=True) + last_name = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + # User's target daily calories + target_calories = Column(Float, default=2000.0) + height_cm = Column(Float, nullable=True) + weight_kg = Column(Float, nullable=True) + date_of_birth = Column(DateTime, nullable=True) + gender = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + calorie_entries = relationship("CalorieEntry", back_populates="user", cascade="all, delete-orphan") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..0e5198f --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,14 @@ +""" +Schemas package initialization +""" +from app.schemas.user import User, UserCreate, UserUpdate, UserInDB +from app.schemas.food import Food, FoodCreate, FoodUpdate +from app.schemas.calorie_entry import CalorieEntry, CalorieEntryCreate, CalorieEntryUpdate, CalorieEntryWithFood +from app.schemas.token import Token, TokenPayload + +__all__ = [ + "User", "UserCreate", "UserUpdate", "UserInDB", + "Food", "FoodCreate", "FoodUpdate", + "CalorieEntry", "CalorieEntryCreate", "CalorieEntryUpdate", "CalorieEntryWithFood", + "Token", "TokenPayload" +] \ No newline at end of file diff --git a/app/schemas/base.py b/app/schemas/base.py new file mode 100644 index 0000000..2a09888 --- /dev/null +++ b/app/schemas/base.py @@ -0,0 +1,17 @@ +""" +Base schemas for the application +""" +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, ConfigDict + + +class BaseSchema(BaseModel): + """Base schema with common fields and config""" + model_config = ConfigDict(from_attributes=True) + + +class TimestampSchema(BaseSchema): + """Schema with timestamp fields""" + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None \ No newline at end of file diff --git a/app/schemas/calorie_entry.py b/app/schemas/calorie_entry.py new file mode 100644 index 0000000..ad2add0 --- /dev/null +++ b/app/schemas/calorie_entry.py @@ -0,0 +1,52 @@ +""" +CalorieEntry schemas for the application +""" +from datetime import datetime +from typing import Optional +from pydantic import Field + +from app.schemas.base import BaseSchema, TimestampSchema +from app.schemas.food import Food + + +# Shared properties +class CalorieEntryBase(BaseSchema): + """Base calorie entry schema with shared properties""" + food_id: Optional[int] = None + quantity_g: Optional[float] = None + meal_type: Optional[str] = None + notes: Optional[str] = None + consumed_at: Optional[datetime] = None + + +# Properties to receive via API on creation +class CalorieEntryCreate(CalorieEntryBase): + """Schema for creating a new calorie entry""" + food_id: int + quantity_g: float = Field(..., gt=0) + consumed_at: Optional[datetime] = datetime.utcnow() + + +# Properties to receive via API on update +class CalorieEntryUpdate(CalorieEntryBase): + """Schema for updating a calorie entry""" + pass + + +class CalorieEntryInDBBase(CalorieEntryBase, TimestampSchema): + """Base schema for calorie entry in database""" + id: int + user_id: int + + +# Additional properties to return via API +class CalorieEntry(CalorieEntryInDBBase): + """Schema for returning a calorie entry via API""" + pass + + +# Expanded calorie entry with food details +class CalorieEntryWithFood(CalorieEntry): + """Schema for returning a calorie entry with food details""" + food: Food + calories: float # Calculated total calories for this entry \ No newline at end of file diff --git a/app/schemas/food.py b/app/schemas/food.py new file mode 100644 index 0000000..8a8f485 --- /dev/null +++ b/app/schemas/food.py @@ -0,0 +1,45 @@ +""" +Food schemas for the application +""" +from typing import Optional +from pydantic import Field + +from app.schemas.base import BaseSchema, TimestampSchema + + +# Shared properties +class FoodBase(BaseSchema): + """Base food schema with shared properties""" + name: Optional[str] = None + description: Optional[str] = None + calories_per_100g: Optional[float] = None + protein_g: Optional[float] = None + carbs_g: Optional[float] = None + fat_g: Optional[float] = None + fiber_g: Optional[float] = None + is_verified: Optional[bool] = False + + +# Properties to receive via API on creation +class FoodCreate(FoodBase): + """Schema for creating a new food item""" + name: str + calories_per_100g: float = Field(..., gt=0) + + +# Properties to receive via API on update +class FoodUpdate(FoodBase): + """Schema for updating a food item""" + pass + + +class FoodInDBBase(FoodBase, TimestampSchema): + """Base schema for food in database""" + id: int + created_by_id: Optional[int] = None + + +# Additional properties to return via API +class Food(FoodInDBBase): + """Schema for returning a food item via API""" + pass \ No newline at end of file diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..6a2496c --- /dev/null +++ b/app/schemas/token.py @@ -0,0 +1,17 @@ +""" +Token schemas for authentication +""" +from typing import Optional +from pydantic import BaseModel + + +class Token(BaseModel): + """Schema for access token""" + access_token: str + token_type: str = "bearer" + + +class TokenPayload(BaseModel): + """Schema for token payload""" + sub: Optional[int] = None # Subject (user ID) + exp: Optional[int] = None # Expiration time \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..8f8d16a --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,55 @@ +""" +User schemas for the application +""" +from datetime import datetime +from typing import Optional +from pydantic import EmailStr + +from app.schemas.base import BaseSchema, TimestampSchema + + +# Shared properties +class UserBase(BaseSchema): + """Base user schema with shared properties""" + email: Optional[EmailStr] = None + username: Optional[str] = None + is_active: Optional[bool] = True + is_superuser: bool = False + first_name: Optional[str] = None + last_name: Optional[str] = None + target_calories: Optional[float] = 2000.0 + height_cm: Optional[float] = None + weight_kg: Optional[float] = None + date_of_birth: Optional[datetime] = None + gender: Optional[str] = None + + +# Properties to receive via API on creation +class UserCreate(UserBase): + """Schema for creating a new user""" + email: EmailStr + username: str + password: str + + +# Properties to receive via API on update +class UserUpdate(UserBase): + """Schema for updating a user""" + password: Optional[str] = None + + +class UserInDBBase(UserBase, TimestampSchema): + """Base schema for user in database""" + id: int + + +# Additional properties to return via API +class User(UserInDBBase): + """Schema for returning a user via API""" + pass + + +# Additional properties stored in DB but not returned by API +class UserInDB(UserInDBBase): + """Schema for user in database with hashed password""" + hashed_password: str \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..458b0eb --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,3 @@ +""" +Services package initialization +""" \ No newline at end of file diff --git a/app/services/calorie_entry.py b/app/services/calorie_entry.py new file mode 100644 index 0000000..36a59c9 --- /dev/null +++ b/app/services/calorie_entry.py @@ -0,0 +1,210 @@ +""" +CalorieEntry service for managing calorie tracking operations +""" +from datetime import datetime, date, timedelta +from typing import Optional, List, Dict, Any, Union + +from sqlalchemy import func +from sqlalchemy.orm import Session, joinedload + +from app import models, schemas + + +def get(db: Session, entry_id: int) -> Optional[models.CalorieEntry]: + """ + Get a calorie entry by ID + """ + return ( + db.query(models.CalorieEntry) + .filter(models.CalorieEntry.id == entry_id) + .options(joinedload(models.CalorieEntry.food)) + .first() + ) + + +def get_multi_by_user( + db: Session, + *, + user_id: int, + skip: int = 0, + limit: int = 100, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + meal_type: Optional[str] = None, +) -> List[models.CalorieEntry]: + """ + Get multiple calorie entries for a specific user with optional filters + """ + query = ( + db.query(models.CalorieEntry) + .filter(models.CalorieEntry.user_id == user_id) + .options(joinedload(models.CalorieEntry.food)) + ) + + if start_date: + query = query.filter( + func.date(models.CalorieEntry.consumed_at) >= start_date + ) + + if end_date: + query = query.filter( + func.date(models.CalorieEntry.consumed_at) <= end_date + ) + + if meal_type: + query = query.filter(models.CalorieEntry.meal_type == meal_type) + + return ( + query.order_by(models.CalorieEntry.consumed_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + +def get_daily_summary( + db: Session, + *, + user_id: int, + date_: date +) -> Dict[str, Any]: + """ + Get a summary of calories for a specific date + """ + start_datetime = datetime.combine(date_, datetime.min.time()) + end_datetime = datetime.combine(date_, datetime.max.time()) + + entries = ( + db.query(models.CalorieEntry) + .join(models.Food, models.CalorieEntry.food_id == models.Food.id) + .filter(models.CalorieEntry.user_id == user_id) + .filter(models.CalorieEntry.consumed_at >= start_datetime) + .filter(models.CalorieEntry.consumed_at <= end_datetime) + .options(joinedload(models.CalorieEntry.food)) + .all() + ) + + # Calculate total calories and nutrients + total_calories = 0.0 + total_protein = 0.0 + total_carbs = 0.0 + total_fat = 0.0 + total_fiber = 0.0 + meal_summary = {} + + for entry in entries: + # Calculate calories for this entry + calories = entry.quantity_g / 100 * entry.food.calories_per_100g + total_calories += calories + + # Calculate nutrients if available + if entry.food.protein_g: + total_protein += entry.quantity_g / 100 * entry.food.protein_g + if entry.food.carbs_g: + total_carbs += entry.quantity_g / 100 * entry.food.carbs_g + if entry.food.fat_g: + total_fat += entry.quantity_g / 100 * entry.food.fat_g + if entry.food.fiber_g: + total_fiber += entry.quantity_g / 100 * entry.food.fiber_g + + # Add to meal summary + meal_type = entry.meal_type or "Other" + if meal_type not in meal_summary: + meal_summary[meal_type] = 0.0 + meal_summary[meal_type] += calories + + # Get user's target calories + user = db.query(models.User).filter(models.User.id == user_id).first() + target_calories = user.target_calories if user else 2000.0 + + return { + "date": date_, + "total_calories": round(total_calories, 1), + "target_calories": target_calories, + "remaining_calories": round(target_calories - total_calories, 1), + "total_protein_g": round(total_protein, 1), + "total_carbs_g": round(total_carbs, 1), + "total_fat_g": round(total_fat, 1), + "total_fiber_g": round(total_fiber, 1), + "meal_summary": {k: round(v, 1) for k, v in meal_summary.items()}, + "entry_count": len(entries), + } + + +def create( + db: Session, *, obj_in: schemas.CalorieEntryCreate, user_id: int +) -> models.CalorieEntry: + """ + Create a new calorie entry + """ + db_obj = models.CalorieEntry( + user_id=user_id, + food_id=obj_in.food_id, + quantity_g=obj_in.quantity_g, + meal_type=obj_in.meal_type, + notes=obj_in.notes, + consumed_at=obj_in.consumed_at or datetime.utcnow(), + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, + *, + db_obj: models.CalorieEntry, + obj_in: Union[schemas.CalorieEntryUpdate, Dict[str, Any]] +) -> models.CalorieEntry: + """ + Update a calorie entry + """ + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + + for field in update_data: + if hasattr(db_obj, field): + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def delete(db: Session, *, entry_id: int) -> models.CalorieEntry: + """ + Delete a calorie entry + """ + entry = db.query(models.CalorieEntry).filter(models.CalorieEntry.id == entry_id).first() + db.delete(entry) + db.commit() + return entry + + +def get_weekly_summary( + db: Session, + *, + user_id: int, + end_date: date = None +) -> List[Dict[str, Any]]: + """ + Get a weekly summary of calories + """ + if end_date is None: + end_date = date.today() + + start_date = end_date - timedelta(days=6) # Last 7 days + + summaries = [] + current_date = start_date + + while current_date <= end_date: + daily_summary = get_daily_summary(db, user_id=user_id, date_=current_date) + summaries.append(daily_summary) + current_date += timedelta(days=1) + + return summaries \ No newline at end of file diff --git a/app/services/food.py b/app/services/food.py new file mode 100644 index 0000000..7a91a52 --- /dev/null +++ b/app/services/food.py @@ -0,0 +1,99 @@ +""" +Food service for managing food operations +""" +from typing import Optional, List, Dict, Any, Union + +from sqlalchemy.orm import Session + +from app import models, schemas + + +def get(db: Session, food_id: int) -> Optional[models.Food]: + """ + Get a food item by ID + """ + return db.query(models.Food).filter(models.Food.id == food_id).first() + + +def get_multi( + db: Session, *, skip: int = 0, limit: int = 100, name_filter: Optional[str] = None +) -> List[models.Food]: + """ + Get multiple food items, with optional name filter + """ + query = db.query(models.Food) + + if name_filter: + query = query.filter(models.Food.name.ilike(f"%{name_filter}%")) + + return query.order_by(models.Food.name).offset(skip).limit(limit).all() + + +def get_multi_by_user( + db: Session, *, user_id: int, skip: int = 0, limit: int = 100 +) -> List[models.Food]: + """ + Get multiple food items created by a specific user + """ + return ( + db.query(models.Food) + .filter(models.Food.created_by_id == user_id) + .order_by(models.Food.name) + .offset(skip) + .limit(limit) + .all() + ) + + +def create( + db: Session, *, obj_in: schemas.FoodCreate, created_by_id: Optional[int] = None +) -> models.Food: + """ + Create a new food item + """ + db_obj = models.Food( + name=obj_in.name, + description=obj_in.description, + calories_per_100g=obj_in.calories_per_100g, + protein_g=obj_in.protein_g, + carbs_g=obj_in.carbs_g, + fat_g=obj_in.fat_g, + fiber_g=obj_in.fiber_g, + is_verified=obj_in.is_verified, + created_by_id=created_by_id, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: models.Food, obj_in: Union[schemas.FoodUpdate, Dict[str, Any]] +) -> models.Food: + """ + Update a food item + """ + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + + for field in update_data: + if hasattr(db_obj, field): + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def delete(db: Session, *, food_id: int) -> models.Food: + """ + Delete a food item + """ + food = db.query(models.Food).filter(models.Food.id == food_id).first() + db.delete(food) + db.commit() + return food \ No newline at end of file diff --git a/app/services/user.py b/app/services/user.py new file mode 100644 index 0000000..6c25c12 --- /dev/null +++ b/app/services/user.py @@ -0,0 +1,126 @@ +""" +User service for managing user operations +""" +from typing import Optional, List, Dict, Any, Union + +from sqlalchemy.orm import Session + +from app import models, schemas +from app.core.security import get_password_hash, verify_password + + +def get_by_email(db: Session, email: str) -> Optional[models.User]: + """ + Get a user by email + """ + return db.query(models.User).filter(models.User.email == email).first() + + +def get_by_username(db: Session, username: str) -> Optional[models.User]: + """ + Get a user by username + """ + return db.query(models.User).filter(models.User.username == username).first() + + +def get(db: Session, user_id: int) -> Optional[models.User]: + """ + Get a user by ID + """ + return db.query(models.User).filter(models.User.id == user_id).first() + + +def get_multi( + db: Session, *, skip: int = 0, limit: int = 100 +) -> List[models.User]: + """ + Get multiple users + """ + return db.query(models.User).offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: schemas.UserCreate) -> models.User: + """ + Create a new user + """ + db_obj = models.User( + email=obj_in.email, + username=obj_in.username, + hashed_password=get_password_hash(obj_in.password), + first_name=obj_in.first_name, + last_name=obj_in.last_name, + target_calories=obj_in.target_calories, + height_cm=obj_in.height_cm, + weight_kg=obj_in.weight_kg, + date_of_birth=obj_in.date_of_birth, + gender=obj_in.gender, + is_superuser=obj_in.is_superuser, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: models.User, obj_in: Union[schemas.UserUpdate, Dict[str, Any]] +) -> models.User: + """ + Update a user + """ + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + + if update_data.get("password"): + hashed_password = get_password_hash(update_data["password"]) + del update_data["password"] + update_data["hashed_password"] = hashed_password + + for field in update_data: + if hasattr(db_obj, field): + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def delete(db: Session, *, user_id: int) -> models.User: + """ + Delete a user + """ + user = db.query(models.User).filter(models.User.id == user_id).first() + db.delete(user) + db.commit() + return user + + +def authenticate(db: Session, *, email_or_username: str, password: str) -> Optional[models.User]: + """ + Authenticate a user with email/username and password + """ + user = get_by_email(db, email=email_or_username) + if not user: + user = get_by_username(db, username=email_or_username) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + +def is_active(user: models.User) -> bool: + """ + Check if a user is active + """ + return user.is_active + + +def is_superuser(user: models.User) -> bool: + """ + Check if a user is a superuser + """ + return user.is_superuser \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..4ce5fbc --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1,3 @@ +""" +Utils package initialization +""" \ No newline at end of file diff --git a/app/utils/calories.py b/app/utils/calories.py new file mode 100644 index 0000000..49a1f6f --- /dev/null +++ b/app/utils/calories.py @@ -0,0 +1,132 @@ +""" +Utility functions for calories calculation +""" +from datetime import date +from enum import Enum +from typing import Dict, Any + + +class ActivityLevel(str, Enum): + """Activity level for BMR calculations""" + SEDENTARY = "sedentary" # Little or no exercise + LIGHTLY_ACTIVE = "lightly_active" # Light exercise/sports 1-3 days/week + MODERATELY_ACTIVE = "moderately_active" # Moderate exercise/sports 3-5 days/week + VERY_ACTIVE = "very_active" # Hard exercise/sports 6-7 days/week + EXTRA_ACTIVE = "extra_active" # Very hard exercise, physical job or training twice a day + + +class WeightGoal(str, Enum): + """Weight goals for calorie adjustments""" + LOSE_FAST = "lose_fast" # Lose 1kg per week + LOSE = "lose" # Lose 0.5kg per week + MAINTAIN = "maintain" # Maintain current weight + GAIN = "gain" # Gain 0.5kg per week + GAIN_FAST = "gain_fast" # Gain 1kg per week + + +def calculate_age(birth_date: date) -> int: + """ + Calculate age from birth date + """ + today = date.today() + age = today.year - birth_date.year + + # Check if birthday has occurred this year + if today.month < birth_date.month or (today.month == birth_date.month and today.day < birth_date.day): + age -= 1 + + return age + + +def calculate_bmr( + weight_kg: float, + height_cm: float, + age: int, + gender: str +) -> float: + """ + Calculate Basal Metabolic Rate (BMR) using the Mifflin-St Jeor Equation + + For men: BMR = (10 × weight in kg) + (6.25 × height in cm) - (5 × age in years) + 5 + For women: BMR = (10 × weight in kg) + (6.25 × height in cm) - (5 × age in years) - 161 + """ + if gender.lower() in ("male", "m"): + return (10 * weight_kg) + (6.25 * height_cm) - (5 * age) + 5 + else: # female + return (10 * weight_kg) + (6.25 * height_cm) - (5 * age) - 161 + + +def calculate_tdee(bmr: float, activity_level: ActivityLevel) -> float: + """ + Calculate Total Daily Energy Expenditure (TDEE) based on BMR and activity level + """ + activity_multipliers = { + ActivityLevel.SEDENTARY: 1.2, + ActivityLevel.LIGHTLY_ACTIVE: 1.375, + ActivityLevel.MODERATELY_ACTIVE: 1.55, + ActivityLevel.VERY_ACTIVE: 1.725, + ActivityLevel.EXTRA_ACTIVE: 1.9, + } + + return bmr * activity_multipliers[activity_level] + + +def adjust_calories_for_goal(tdee: float, goal: WeightGoal) -> float: + """ + Adjust TDEE based on weight goal + + 1kg of fat is approximately 7700 calories + To lose/gain 1kg per week: 7700 / 7 = 1100 calories per day + To lose/gain 0.5kg per week: 7700 / 14 = 550 calories per day + """ + goal_adjustments = { + WeightGoal.LOSE_FAST: -1100, + WeightGoal.LOSE: -550, + WeightGoal.MAINTAIN: 0, + WeightGoal.GAIN: 550, + WeightGoal.GAIN_FAST: 1100, + } + + return tdee + goal_adjustments[goal] + + +def calculate_recommended_calories( + weight_kg: float, + height_cm: float, + birth_date: date, + gender: str, + activity_level: ActivityLevel, + goal: WeightGoal +) -> Dict[str, Any]: + """ + Calculate recommended daily calories based on personal data and goals + """ + age = calculate_age(birth_date) + bmr = calculate_bmr(weight_kg, height_cm, age, gender) + tdee = calculate_tdee(bmr, activity_level) + adjusted_calories = adjust_calories_for_goal(tdee, goal) + + # Calculate macronutrient recommendations based on a balanced diet + # Protein: 30%, Carbs: 40%, Fat: 30% + calories_from_protein = adjusted_calories * 0.3 + calories_from_carbs = adjusted_calories * 0.4 + calories_from_fat = adjusted_calories * 0.3 + + # Convert calories to grams + # Protein: 4 calories per gram + # Carbs: 4 calories per gram + # Fat: 9 calories per gram + protein_g = calories_from_protein / 4 + carbs_g = calories_from_carbs / 4 + fat_g = calories_from_fat / 9 + + return { + "bmr": round(bmr), + "tdee": round(tdee), + "recommended_calories": round(adjusted_calories), + "macronutrients": { + "protein_g": round(protein_g), + "carbs_g": round(carbs_g), + "fat_g": round(fat_g), + } + } \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..223c7c6 --- /dev/null +++ b/main.py @@ -0,0 +1,55 @@ +""" +Calories Calculator API +""" +import pathlib +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.api.v1.api import api_router +from app.core.config import settings + +# Create database directory if it doesn't exist +DB_DIR = pathlib.Path("/app") / "storage" / "db" +DB_DIR.mkdir(parents=True, exist_ok=True) + +app = FastAPI( + title="Calories Calculator API", + description="API for tracking calories and nutritional information", + version="0.1.0", + openapi_url="/openapi.json", +) + +# CORS middleware setup +app.add_middleware( + CORSMiddleware, + allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], + allow_credentials=True, + allow_methods=["*"], # Allows all methods + allow_headers=["*"], # Allows all headers +) + +# Include API router +app.include_router(api_router, prefix=settings.API_V1_STR) + + +@app.get("/health", tags=["Health"]) +async def health_check(): + """ + Health check endpoint for the API. + """ + return JSONResponse({"status": "healthy"}) + + +@app.get("/openapi.json", include_in_schema=False) +async def get_open_api_endpoint(): + """ + Return the OpenAPI schema for the API. + """ + return app.openapi() + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..87a7f31 --- /dev/null +++ b/migrations/__init__.py @@ -0,0 +1,3 @@ +""" +Migrations package initialization +""" \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..93f3acd --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,85 @@ +""" +Alembic environment configuration +""" +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from app.db.base import Base +# Import models to ensure they are registered with the Base metadata +import app.models # noqa: F401 + +# 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. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +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(): + """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(): + """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: + is_sqlite = connection.dialect.name == "sqlite" + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, # Set batch mode for SQLite + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1e4564e --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/001_initial_tables.py b/migrations/versions/001_initial_tables.py new file mode 100644 index 0000000..5e66f4d --- /dev/null +++ b/migrations/versions/001_initial_tables.py @@ -0,0 +1,93 @@ +"""Initial tables + +Revision ID: 001 +Revises: +Create Date: 2023-09-26 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # Create user table + op.create_table( + 'user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('first_name', sa.String(), nullable=True), + sa.Column('last_name', sa.String(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True, default=True), + sa.Column('is_superuser', sa.Boolean(), nullable=True, default=False), + sa.Column('target_calories', sa.Float(), nullable=True, default=2000.0), + sa.Column('height_cm', sa.Float(), nullable=True), + sa.Column('weight_kg', sa.Float(), nullable=True), + sa.Column('date_of_birth', sa.DateTime(), nullable=True), + sa.Column('gender', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) + op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False) + op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) + + # Create food table + op.create_table( + 'food', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('calories_per_100g', sa.Float(), nullable=False), + sa.Column('protein_g', sa.Float(), nullable=True), + sa.Column('carbs_g', sa.Float(), nullable=True), + sa.Column('fat_g', sa.Float(), nullable=True), + sa.Column('fiber_g', sa.Float(), nullable=True), + sa.Column('is_verified', sa.Boolean(), nullable=True, default=False), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_food_id'), 'food', ['id'], unique=False) + op.create_index(op.f('ix_food_name'), 'food', ['name'], unique=False) + + # Create calorie_entry table + op.create_table( + 'calorieentry', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('food_id', sa.Integer(), nullable=False), + sa.Column('quantity_g', sa.Float(), nullable=False), + sa.Column('meal_type', sa.String(), nullable=True), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('consumed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['food_id'], ['food.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_calorieentry_id'), 'calorieentry', ['id'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_calorieentry_id'), table_name='calorieentry') + op.drop_table('calorieentry') + op.drop_index(op.f('ix_food_name'), table_name='food') + op.drop_index(op.f('ix_food_id'), table_name='food') + op.drop_table('food') + op.drop_index(op.f('ix_user_username'), table_name='user') + op.drop_index(op.f('ix_user_id'), table_name='user') + op.drop_index(op.f('ix_user_email'), table_name='user') + op.drop_table('user') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b14e5cd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.103.1 +uvicorn>=0.23.2 +pydantic>=2.3.0 +pydantic-settings>=2.0.3 +pydantic[email]>=2.3.0 +sqlalchemy>=2.0.20 +alembic>=1.12.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-multipart>=0.0.6 +email-validator>=2.0.0 +ruff>=0.0.292 \ No newline at end of file