main commit
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-10-16 16:30:25 +09:00
parent 91c7e04474
commit 537e7b363f
1146 changed files with 45926 additions and 77196 deletions

View File

@@ -0,0 +1,462 @@
from datetime import date, datetime, timedelta
from typing import Dict, List, Optional, Any
from fastapi import Depends, FastAPI, HTTPException, Query, Path, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import and_, desc, select, func, text
from sqlalchemy.ext.asyncio import AsyncSession
from services.nutrition_service.models import (
FoodItem, UserNutritionEntry, WaterIntake,
UserActivityEntry, NutritionGoal
)
from services.nutrition_service.schemas import (
FoodItemCreate, FoodItemResponse, UserNutritionEntryCreate,
UserNutritionEntryResponse, WaterIntakeCreate, WaterIntakeResponse,
UserActivityEntryCreate, UserActivityEntryResponse,
NutritionGoalCreate, NutritionGoalResponse,
FoodSearchQuery, FoodDetailsQuery, DailyNutritionSummary
)
from services.nutrition_service.fatsecret_client import FatSecretClient
from shared.auth import get_current_user_from_token
from shared.config import settings
from shared.database import get_db
app = FastAPI(title="Nutrition Service", version="1.0.0")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Создаем клиент FatSecret
fatsecret_client = FatSecretClient()
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "nutrition_service"}
# Эндпоинты для работы с API FatSecret
@app.post("/api/v1/nutrition/search", response_model=List[FoodItemResponse])
async def search_foods(
search_query: FoodSearchQuery,
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Поиск продуктов питания по запросу в FatSecret API"""
try:
# Вызов API FatSecret для поиска продуктов
search_results = await fatsecret_client.search_foods(
search_query.query,
search_query.page_number,
search_query.max_results
)
# Обработка результатов поиска
foods = []
if 'foods' in search_results and 'food' in search_results['foods']:
food_list = search_results['foods']['food']
# Если результат всего один, API возвращает словарь вместо списка
if isinstance(food_list, dict):
food_list = [food_list]
for food in food_list:
# Получение деталей о продукте
food_details = await fatsecret_client.get_food_details(food['food_id'])
parsed_food = await fatsecret_client.parse_food_data(food_details)
# Проверяем, существует ли продукт в базе данных
query = select(FoodItem).where(FoodItem.fatsecret_id == parsed_food['fatsecret_id'])
result = await db.execute(query)
db_food = result.scalars().first()
# Если продукт не существует, сохраняем его
if not db_food:
db_food = FoodItem(**parsed_food)
db.add(db_food)
await db.commit()
await db.refresh(db_food)
foods.append(FoodItemResponse.model_validate(db_food))
return foods
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error searching foods: {str(e)}"
)
@app.get("/api/v1/nutrition/food/{food_id}", response_model=FoodItemResponse)
async def get_food_details(
food_id: int = Path(..., description="ID продукта в базе данных"),
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Получение детальной информации о продукте по ID из базы данных"""
query = select(FoodItem).where(FoodItem.id == food_id)
result = await db.execute(query)
food = result.scalars().first()
if not food:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Food item not found"
)
return FoodItemResponse.model_validate(food)
@app.get("/api/v1/nutrition/fatsecret/{fatsecret_id}", response_model=FoodItemResponse)
async def get_food_by_fatsecret_id(
fatsecret_id: str = Path(..., description="ID продукта в FatSecret"),
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Получение детальной информации о продукте по FatSecret ID"""
# Проверяем, есть ли продукт в нашей базе данных
query = select(FoodItem).where(FoodItem.fatsecret_id == fatsecret_id)
result = await db.execute(query)
food = result.scalars().first()
# Если продукт не найден в базе, запрашиваем его с FatSecret API
if not food:
try:
food_details = await fatsecret_client.get_food_details(fatsecret_id)
parsed_food = await fatsecret_client.parse_food_data(food_details)
food = FoodItem(**parsed_food)
db.add(food)
await db.commit()
await db.refresh(food)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching food details: {str(e)}"
)
return FoodItemResponse.model_validate(food)
# Эндпоинты для работы с записями питания пользователя
@app.post("/api/v1/nutrition/entries", response_model=UserNutritionEntryResponse)
async def create_nutrition_entry(
entry_data: UserNutritionEntryCreate,
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Создание новой записи о питании пользователя"""
# Получаем ID пользователя из токена
user_id = user_data["user_id"]
# Если указан ID продукта, проверяем его наличие
food_item = None
if entry_data.food_item_id:
query = select(FoodItem).where(FoodItem.id == entry_data.food_item_id)
result = await db.execute(query)
food_item = result.scalars().first()
if not food_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Food item not found"
)
# Создаем данные для записи
nutrition_data = entry_data.model_dump(exclude={"food_item_id"})
nutrition_entry = UserNutritionEntry(**nutrition_data, user_id=user_id)
if food_item:
nutrition_entry.food_item_id = food_item.id
# Если питательные данные не указаны, рассчитываем их на основе продукта
if not entry_data.calories and food_item.calories:
nutrition_entry.calories = food_item.calories * entry_data.quantity
if not entry_data.protein_grams and food_item.protein_grams:
nutrition_entry.protein_grams = food_item.protein_grams * entry_data.quantity
if not entry_data.fat_grams and food_item.fat_grams:
nutrition_entry.fat_grams = food_item.fat_grams * entry_data.quantity
if not entry_data.carbs_grams and food_item.carbs_grams:
nutrition_entry.carbs_grams = food_item.carbs_grams * entry_data.quantity
db.add(nutrition_entry)
await db.commit()
await db.refresh(nutrition_entry)
return UserNutritionEntryResponse.model_validate(nutrition_entry)
@app.get("/api/v1/nutrition/entries", response_model=List[UserNutritionEntryResponse])
async def get_user_nutrition_entries(
start_date: date = Query(..., description="Начальная дата для выборки"),
end_date: date = Query(..., description="Конечная дата для выборки"),
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Получение записей о питании пользователя за указанный период"""
user_id = user_data["user_id"]
query = (
select(UserNutritionEntry)
.where(
and_(
UserNutritionEntry.user_id == user_id,
UserNutritionEntry.entry_date >= start_date,
UserNutritionEntry.entry_date <= end_date
)
)
.order_by(UserNutritionEntry.entry_date, UserNutritionEntry.meal_type)
)
result = await db.execute(query)
entries = result.scalars().all()
return [UserNutritionEntryResponse.model_validate(entry) for entry in entries]
# Эндпоинты для работы с записями о потреблении воды
@app.post("/api/v1/nutrition/water", response_model=WaterIntakeResponse)
async def create_water_intake(
intake_data: WaterIntakeCreate,
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Создание новой записи о потреблении воды"""
user_id = user_data["user_id"]
water_intake = WaterIntake(**intake_data.model_dump(), user_id=user_id)
db.add(water_intake)
await db.commit()
await db.refresh(water_intake)
return WaterIntakeResponse.model_validate(water_intake)
@app.get("/api/v1/nutrition/water", response_model=List[WaterIntakeResponse])
async def get_user_water_intake(
start_date: date = Query(..., description="Начальная дата для выборки"),
end_date: date = Query(..., description="Конечная дата для выборки"),
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Получение записей о потреблении воды за указанный период"""
user_id = user_data["user_id"]
query = (
select(WaterIntake)
.where(
and_(
WaterIntake.user_id == user_id,
WaterIntake.entry_date >= start_date,
WaterIntake.entry_date <= end_date
)
)
.order_by(WaterIntake.entry_date, WaterIntake.entry_time)
)
result = await db.execute(query)
entries = result.scalars().all()
return [WaterIntakeResponse.model_validate(entry) for entry in entries]
# Эндпоинты для работы с записями о физической активности
@app.post("/api/v1/nutrition/activity", response_model=UserActivityEntryResponse)
async def create_activity_entry(
activity_data: UserActivityEntryCreate,
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Создание новой записи о физической активности"""
user_id = user_data["user_id"]
# Если не указаны сожженные калории, рассчитываем примерно
if not activity_data.calories_burned:
# Простой расчет на основе типа активности и продолжительности
# Точный расчет требует больше параметров (вес, рост, возраст, пол)
activity_intensity = {
"walking": 5, # ккал/мин
"running": 10,
"cycling": 8,
"swimming": 9,
"yoga": 4,
"weight_training": 6,
"hiit": 12,
"pilates": 5,
}
activity_type = activity_data.activity_type.lower()
intensity = activity_intensity.get(activity_type, 5) # По умолчанию 5 ккал/мин
# Увеличиваем интенсивность в зависимости от указанной интенсивности
if activity_data.intensity == "high":
intensity *= 1.5
elif activity_data.intensity == "low":
intensity *= 0.8
calories_burned = intensity * activity_data.duration_minutes
activity_data.calories_burned = round(calories_burned, 1)
activity_entry = UserActivityEntry(**activity_data.model_dump(), user_id=user_id)
db.add(activity_entry)
await db.commit()
await db.refresh(activity_entry)
return UserActivityEntryResponse.model_validate(activity_entry)
@app.get("/api/v1/nutrition/activity", response_model=List[UserActivityEntryResponse])
async def get_user_activities(
start_date: date = Query(..., description="Начальная дата для выборки"),
end_date: date = Query(..., description="Конечная дата для выборки"),
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Получение записей о физической активности за указанный период"""
user_id = user_data["user_id"]
query = (
select(UserActivityEntry)
.where(
and_(
UserActivityEntry.user_id == user_id,
UserActivityEntry.entry_date >= start_date,
UserActivityEntry.entry_date <= end_date
)
)
.order_by(UserActivityEntry.entry_date, UserActivityEntry.created_at)
)
result = await db.execute(query)
entries = result.scalars().all()
return [UserActivityEntryResponse.model_validate(entry) for entry in entries]
# Эндпоинты для работы с целями питания
@app.post("/api/v1/nutrition/goals", response_model=NutritionGoalResponse)
async def create_or_update_nutrition_goals(
goal_data: NutritionGoalCreate,
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Создание или обновление целей по питанию и активности"""
user_id = user_data["user_id"]
# Проверяем, существуют ли уже цели для пользователя
query = select(NutritionGoal).where(NutritionGoal.user_id == user_id)
result = await db.execute(query)
existing_goal = result.scalars().first()
if existing_goal:
# Обновляем существующую цель
for key, value in goal_data.model_dump(exclude_unset=True).items():
setattr(existing_goal, key, value)
await db.commit()
await db.refresh(existing_goal)
return NutritionGoalResponse.model_validate(existing_goal)
else:
# Создаем новую цель
new_goal = NutritionGoal(**goal_data.model_dump(), user_id=user_id)
db.add(new_goal)
await db.commit()
await db.refresh(new_goal)
return NutritionGoalResponse.model_validate(new_goal)
@app.get("/api/v1/nutrition/goals", response_model=NutritionGoalResponse)
async def get_nutrition_goals(
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Получение целей пользователя по питанию и активности"""
user_id = user_data["user_id"]
query = select(NutritionGoal).where(NutritionGoal.user_id == user_id)
result = await db.execute(query)
goal = result.scalars().first()
if not goal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Nutrition goals not found for this user"
)
return NutritionGoalResponse.model_validate(goal)
# Сводные отчеты
@app.get("/api/v1/nutrition/summary/daily", response_model=DailyNutritionSummary)
async def get_daily_nutrition_summary(
target_date: date = Query(..., description="Дата для получения сводки"),
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Получение дневной сводки по питанию, потреблению воды и физической активности"""
user_id = user_data["user_id"]
# Запрос записей о питании
meals_query = select(UserNutritionEntry).where(
and_(
UserNutritionEntry.user_id == user_id,
UserNutritionEntry.entry_date == target_date
)
).order_by(UserNutritionEntry.meal_type)
meals_result = await db.execute(meals_query)
meals = meals_result.scalars().all()
# Запрос записей о воде
water_query = select(WaterIntake).where(
and_(
WaterIntake.user_id == user_id,
WaterIntake.entry_date == target_date
)
).order_by(WaterIntake.entry_time)
water_result = await db.execute(water_query)
water_entries = water_result.scalars().all()
# Запрос записей об активности
activity_query = select(UserActivityEntry).where(
and_(
UserActivityEntry.user_id == user_id,
UserActivityEntry.entry_date == target_date
)
).order_by(UserActivityEntry.created_at)
activity_result = await db.execute(activity_query)
activity_entries = activity_result.scalars().all()
# Расчет суммарных значений
total_calories = sum(meal.calories or 0 for meal in meals)
total_protein = sum(meal.protein_grams or 0 for meal in meals)
total_fat = sum(meal.fat_grams or 0 for meal in meals)
total_carbs = sum(meal.carbs_grams or 0 for meal in meals)
total_water = sum(water.amount_ml for water in water_entries)
total_activity = sum(activity.duration_minutes for activity in activity_entries)
calories_burned = sum(activity.calories_burned or 0 for activity in activity_entries)
# Формирование ответа
summary = DailyNutritionSummary(
date=target_date,
total_calories=total_calories,
total_protein_grams=total_protein,
total_fat_grams=total_fat,
total_carbs_grams=total_carbs,
total_water_ml=total_water,
total_activity_minutes=total_activity,
estimated_calories_burned=calories_burned,
meals=[UserNutritionEntryResponse.model_validate(meal) for meal in meals],
water_entries=[WaterIntakeResponse.model_validate(water) for water in water_entries],
activity_entries=[UserActivityEntryResponse.model_validate(activity) for activity in activity_entries]
)
return summary