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) # Преобразуем типы для Pydantic validation response_data = { 'id': nutrition_entry.id, 'uuid': str(nutrition_entry.uuid), 'user_id': nutrition_entry.user_id, 'entry_date': nutrition_entry.entry_date, 'meal_type': nutrition_entry.meal_type, 'food_item_id': nutrition_entry.food_item_id, 'custom_food_name': nutrition_entry.custom_food_name, 'quantity': nutrition_entry.quantity, 'unit': nutrition_entry.unit, 'calories': nutrition_entry.calories, 'protein_grams': nutrition_entry.protein_grams, 'fat_grams': nutrition_entry.fat_grams, 'carbs_grams': nutrition_entry.carbs_grams, 'notes': nutrition_entry.notes, 'created_at': nutrition_entry.created_at.isoformat() if hasattr(nutrition_entry.created_at, 'isoformat') else str(nutrition_entry.created_at), 'updated_at': nutrition_entry.updated_at.isoformat() if hasattr(nutrition_entry.updated_at, 'isoformat') else str(nutrition_entry.updated_at), } return UserNutritionEntryResponse(**response_data) @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