Implement calories calculator API with FastAPI
This commit is contained in:
parent
3162a7d094
commit
2f6fcf68fe
134
README.md
134
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 <repository-url>
|
||||||
|
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.
|
||||||
|
84
alembic.ini
Normal file
84
alembic.ini
Normal file
@ -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
|
3
app/__init__.py
Normal file
3
app/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
App package initialization
|
||||||
|
"""
|
3
app/api/__init__.py
Normal file
3
app/api/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
API package initialization
|
||||||
|
"""
|
3
app/api/v1/__init__.py
Normal file
3
app/api/v1/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
API v1 package initialization
|
||||||
|
"""
|
13
app/api/v1/api.py
Normal file
13
app/api/v1/api.py
Normal file
@ -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"])
|
3
app/api/v1/endpoints/__init__.py
Normal file
3
app/api/v1/endpoints/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
API v1 endpoints package initialization
|
||||||
|
"""
|
76
app/api/v1/endpoints/auth.py
Normal file
76
app/api/v1/endpoints/auth.py
Normal file
@ -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
|
78
app/api/v1/endpoints/calculator.py
Normal file
78
app/api/v1/endpoints/calculator.py
Normal file
@ -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
|
231
app/api/v1/endpoints/calorie_entries.py
Normal file
231
app/api/v1/endpoints/calorie_entries.py
Normal file
@ -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
|
165
app/api/v1/endpoints/foods.py
Normal file
165
app/api/v1/endpoints/foods.py
Normal file
@ -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
|
131
app/api/v1/endpoints/users.py
Normal file
131
app/api/v1/endpoints/users.py
Normal file
@ -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
|
3
app/core/__init__.py
Normal file
3
app/core/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Core package initialization
|
||||||
|
"""
|
40
app/core/config.py
Normal file
40
app/core/config.py
Normal file
@ -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()
|
64
app/core/deps.py
Normal file
64
app/core/deps.py
Normal file
@ -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
|
43
app/core/security.py
Normal file
43
app/core/security.py
Normal file
@ -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)
|
31
app/db/base.py
Normal file
31
app/db/base.py
Normal file
@ -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()
|
30
app/db/session.py
Normal file
30
app/db/session.py
Normal file
@ -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()
|
8
app/models/__init__.py
Normal file
8
app/models/__init__.py
Normal file
@ -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"]
|
25
app/models/calorie_entry.py
Normal file
25
app/models/calorie_entry.py
Normal file
@ -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")
|
29
app/models/food.py
Normal file
29
app/models/food.py
Normal file
@ -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")
|
31
app/models/user.py
Normal file
31
app/models/user.py
Normal file
@ -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")
|
14
app/schemas/__init__.py
Normal file
14
app/schemas/__init__.py
Normal file
@ -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"
|
||||||
|
]
|
17
app/schemas/base.py
Normal file
17
app/schemas/base.py
Normal file
@ -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
|
52
app/schemas/calorie_entry.py
Normal file
52
app/schemas/calorie_entry.py
Normal file
@ -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
|
45
app/schemas/food.py
Normal file
45
app/schemas/food.py
Normal file
@ -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
|
17
app/schemas/token.py
Normal file
17
app/schemas/token.py
Normal file
@ -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
|
55
app/schemas/user.py
Normal file
55
app/schemas/user.py
Normal file
@ -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
|
3
app/services/__init__.py
Normal file
3
app/services/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Services package initialization
|
||||||
|
"""
|
210
app/services/calorie_entry.py
Normal file
210
app/services/calorie_entry.py
Normal file
@ -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
|
99
app/services/food.py
Normal file
99
app/services/food.py
Normal file
@ -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
|
126
app/services/user.py
Normal file
126
app/services/user.py
Normal file
@ -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
|
3
app/utils/__init__.py
Normal file
3
app/utils/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Utils package initialization
|
||||||
|
"""
|
132
app/utils/calories.py
Normal file
132
app/utils/calories.py
Normal file
@ -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),
|
||||||
|
}
|
||||||
|
}
|
55
main.py
Normal file
55
main.py
Normal file
@ -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)
|
3
migrations/__init__.py
Normal file
3
migrations/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Migrations package initialization
|
||||||
|
"""
|
85
migrations/env.py
Normal file
85
migrations/env.py
Normal file
@ -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()
|
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@ -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"}
|
93
migrations/versions/001_initial_tables.py
Normal file
93
migrations/versions/001_initial_tables.py
Normal file
@ -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')
|
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user