This commit is contained in:
@@ -59,6 +59,7 @@ SERVICES = {
|
||||
"location": os.getenv("LOCATION_SERVICE_URL", "http://localhost:8003"),
|
||||
"calendar": os.getenv("CALENDAR_SERVICE_URL", "http://localhost:8004"),
|
||||
"notifications": os.getenv("NOTIFICATION_SERVICE_URL", "http://localhost:8005"),
|
||||
"nutrition": os.getenv("NUTRITION_SERVICE_URL", "http://localhost:8006"),
|
||||
}
|
||||
|
||||
# Rate limiting (simple in-memory implementation)
|
||||
@@ -732,6 +733,7 @@ async def root():
|
||||
"location": "/api/v1/locations/update, /api/v1/locations/safe-places",
|
||||
"calendar": "/api/v1/calendar/entries, /api/v1/calendar/cycle-overview",
|
||||
"notifications": "/api/v1/notifications/devices, /api/v1/notifications/history",
|
||||
"nutrition": "/api/v1/nutrition/foods, /api/v1/nutrition/daily-summary",
|
||||
},
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
0
services/nutrition_service/__init__.py
Normal file
0
services/nutrition_service/__init__.py
Normal file
199
services/nutrition_service/fatsecret_client.py
Normal file
199
services/nutrition_service/fatsecret_client.py
Normal file
@@ -0,0 +1,199 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
|
||||
import httpx
|
||||
|
||||
from shared.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FatSecretClient:
|
||||
"""Клиент для работы с API FatSecret"""
|
||||
|
||||
BASE_URL = "https://platform.fatsecret.com/rest/server.api"
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация клиента для работы с API FatSecret"""
|
||||
# Используем CUSTOMER_KEY для OAuth 1.0, если он доступен, иначе CLIENT_ID
|
||||
self.api_key = settings.FATSECRET_CUSTOMER_KEY or settings.FATSECRET_CLIENT_ID
|
||||
self.api_secret = settings.FATSECRET_CLIENT_SECRET
|
||||
|
||||
# Логируем информацию о ключах (без полного раскрытия)
|
||||
logger.info(f"FatSecretClient initialized with key: {self.api_key[:8]}...")
|
||||
|
||||
def _generate_oauth_params(self, http_method: str, url: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Создание и подписание OAuth 1.0 параметров"""
|
||||
# Текущее время в секундах
|
||||
timestamp = str(int(time.time()))
|
||||
# Случайная строка для nonce
|
||||
nonce = ''.join([str(random.randint(0, 9)) for _ in range(8)])
|
||||
|
||||
# Базовый набор параметров OAuth
|
||||
oauth_params = {
|
||||
'oauth_consumer_key': self.api_key,
|
||||
'oauth_nonce': nonce,
|
||||
'oauth_signature_method': 'HMAC-SHA1',
|
||||
'oauth_timestamp': timestamp,
|
||||
'oauth_version': '1.0'
|
||||
}
|
||||
|
||||
# Объединяем с параметрами запроса
|
||||
all_params = {**params, **oauth_params}
|
||||
|
||||
# Сортируем параметры по ключу
|
||||
sorted_params = sorted(all_params.items())
|
||||
|
||||
# Создаем строку параметров для подписи
|
||||
param_string = "&".join([
|
||||
f"{urllib.parse.quote(str(k), safe='')}={urllib.parse.quote(str(v), safe='')}"
|
||||
for k, v in sorted_params
|
||||
])
|
||||
|
||||
# Создаем строку для подписи
|
||||
signature_base = f"{http_method}&{urllib.parse.quote(url, safe='')}&{urllib.parse.quote(param_string, safe='')}"
|
||||
|
||||
# Создаем ключ для подписи
|
||||
signing_key = f"{urllib.parse.quote(str(self.api_secret), safe='')}&"
|
||||
|
||||
# Создаем HMAC-SHA1 подпись
|
||||
signature = base64.b64encode(
|
||||
hmac.new(
|
||||
signing_key.encode(),
|
||||
signature_base.encode(),
|
||||
hashlib.sha1
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
# Добавляем подпись к параметрам OAuth
|
||||
all_params['oauth_signature'] = signature
|
||||
|
||||
return all_params
|
||||
|
||||
async def search_foods(self, query: str, page_number: int = 0, max_results: int = 10) -> Dict[str, Any]:
|
||||
"""Поиск продуктов по запросу"""
|
||||
params = {
|
||||
'method': 'foods.search',
|
||||
'search_expression': query,
|
||||
'page_number': str(page_number),
|
||||
'max_results': str(max_results),
|
||||
'format': 'json'
|
||||
}
|
||||
|
||||
# Получаем подписанные OAuth параметры
|
||||
oauth_params = self._generate_oauth_params("GET", self.BASE_URL, params)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
self.BASE_URL,
|
||||
params=oauth_params
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching foods: {e}")
|
||||
raise
|
||||
|
||||
async def get_food_details(self, food_id: Union[str, int]) -> Dict[str, Any]:
|
||||
"""Получить детальную информацию о продукте по ID"""
|
||||
params = {
|
||||
'method': 'food.get.v2',
|
||||
'food_id': str(food_id),
|
||||
'format': 'json'
|
||||
}
|
||||
|
||||
# Получаем подписанные OAuth параметры
|
||||
oauth_params = self._generate_oauth_params("GET", self.BASE_URL, params)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
self.BASE_URL,
|
||||
params=oauth_params
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting food details: {e}")
|
||||
raise
|
||||
|
||||
async def parse_food_data(self, food_json: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Разбирает данные о продукте из API в более удобный формат"""
|
||||
try:
|
||||
food = food_json.get('food', {})
|
||||
|
||||
# Извлечение основной информации о продукте
|
||||
food_id = food.get('food_id')
|
||||
food_name = food.get('food_name', '')
|
||||
food_type = food.get('food_type', '')
|
||||
brand_name = food.get('brand_name', '')
|
||||
|
||||
# Обработка информации о питании
|
||||
servings = food.get('servings', {}).get('serving', [])
|
||||
|
||||
# Если есть только одна порция, преобразуем ее в список
|
||||
if isinstance(servings, dict):
|
||||
servings = [servings]
|
||||
|
||||
# Берем первую порцию по умолчанию (обычно это 100г или стандартная порция)
|
||||
serving_data = {}
|
||||
for serving in servings:
|
||||
if serving.get('is_default_serving', 0) == "1" or serving.get('serving_description', '').lower() == '100g':
|
||||
serving_data = serving
|
||||
break
|
||||
|
||||
# Если не нашли стандартную порцию, берем первую
|
||||
if not serving_data and servings:
|
||||
serving_data = servings[0]
|
||||
|
||||
# Извлечение данных о пищевой ценности
|
||||
serving_description = serving_data.get('serving_description', '')
|
||||
serving_amount = serving_data.get('metric_serving_amount', serving_data.get('serving_amount', ''))
|
||||
serving_unit = serving_data.get('metric_serving_unit', serving_data.get('serving_unit', ''))
|
||||
|
||||
# Формирование читаемого текста размера порции
|
||||
serving_size = f"{serving_amount} {serving_unit}" if serving_amount and serving_unit else serving_description
|
||||
|
||||
# Извлечение данных о пищевой ценности
|
||||
calories = float(serving_data.get('calories', 0) or 0)
|
||||
protein = float(serving_data.get('protein', 0) or 0)
|
||||
fat = float(serving_data.get('fat', 0) or 0)
|
||||
carbs = float(serving_data.get('carbohydrate', 0) or 0)
|
||||
fiber = float(serving_data.get('fiber', 0) or 0)
|
||||
sugar = float(serving_data.get('sugar', 0) or 0)
|
||||
sodium = float(serving_data.get('sodium', 0) or 0)
|
||||
cholesterol = float(serving_data.get('cholesterol', 0) or 0)
|
||||
|
||||
# Формирование результата
|
||||
result = {
|
||||
"fatsecret_id": food_id,
|
||||
"name": food_name,
|
||||
"brand": brand_name,
|
||||
"food_type": food_type,
|
||||
"serving_size": serving_size,
|
||||
"serving_weight_grams": float(serving_amount) if serving_unit == 'g' else None,
|
||||
"calories": calories,
|
||||
"protein_grams": protein,
|
||||
"fat_grams": fat,
|
||||
"carbs_grams": carbs,
|
||||
"fiber_grams": fiber,
|
||||
"sugar_grams": sugar,
|
||||
"sodium_mg": sodium,
|
||||
"cholesterol_mg": cholesterol,
|
||||
"is_verified": True
|
||||
}
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing food data: {e}")
|
||||
raise
|
||||
462
services/nutrition_service/main.py
Normal file
462
services/nutrition_service/main.py
Normal 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
|
||||
146
services/nutrition_service/models.py
Normal file
146
services/nutrition_service/models.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Column, Date, Float, Integer, String, Text, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.sql.expression import text
|
||||
from sqlalchemy.sql.sqltypes import TIMESTAMP
|
||||
|
||||
from shared.database import BaseModel
|
||||
|
||||
|
||||
class FoodItem(BaseModel):
|
||||
"""Модель для хранения информации о продуктах питания"""
|
||||
__tablename__ = "food_items"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
|
||||
|
||||
# Основная информация о продукте
|
||||
fatsecret_id = Column(String(50), unique=True, index=True, nullable=True) # ID продукта в FatSecret
|
||||
name = Column(String(255), nullable=False)
|
||||
brand = Column(String(255), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
food_type = Column(String(50), nullable=True) # generic, branded, etc.
|
||||
serving_size = Column(String(100), nullable=True) # e.g. "1 cup" or "100g"
|
||||
serving_weight_grams = Column(Float, nullable=True)
|
||||
|
||||
# Пищевая ценность на порцию
|
||||
calories = Column(Float, nullable=True) # kcal
|
||||
protein_grams = Column(Float, nullable=True)
|
||||
fat_grams = Column(Float, nullable=True)
|
||||
carbs_grams = Column(Float, nullable=True)
|
||||
fiber_grams = Column(Float, nullable=True)
|
||||
sugar_grams = Column(Float, nullable=True)
|
||||
sodium_mg = Column(Float, nullable=True)
|
||||
cholesterol_mg = Column(Float, nullable=True)
|
||||
|
||||
# Дополнительная информация
|
||||
ingredients = Column(Text, nullable=True)
|
||||
is_verified = Column(Boolean, default=False) # Проверенные данные или пользовательские
|
||||
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP(timezone=True), onupdate=func.now())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FoodItem {self.name}>"
|
||||
|
||||
|
||||
class UserNutritionEntry(BaseModel):
|
||||
"""Модель для хранения записей пользователя о потреблении пищи"""
|
||||
__tablename__ = "user_nutrition_entries"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
|
||||
user_id = Column(Integer, nullable=False, index=True) # Связь с таблицей пользователей
|
||||
|
||||
# Информация о приеме пищи
|
||||
entry_date = Column(Date, nullable=False, index=True)
|
||||
meal_type = Column(String(50), nullable=False) # breakfast, lunch, dinner, snack
|
||||
|
||||
food_item_id = Column(Integer, ForeignKey("food_items.id"), nullable=True)
|
||||
custom_food_name = Column(String(255), nullable=True) # Если продукт не из базы
|
||||
|
||||
# Количество
|
||||
quantity = Column(Float, nullable=False, default=1.0)
|
||||
unit = Column(String(50), nullable=True) # g, ml, oz, piece, etc.
|
||||
|
||||
# Рассчитанная пищевая ценность для данного количества
|
||||
calories = Column(Float, nullable=True)
|
||||
protein_grams = Column(Float, nullable=True)
|
||||
fat_grams = Column(Float, nullable=True)
|
||||
carbs_grams = Column(Float, nullable=True)
|
||||
|
||||
# Метаданные
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP(timezone=True), onupdate=func.now())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserNutritionEntry user_id={self.user_id} date={self.entry_date} meal={self.meal_type}>"
|
||||
|
||||
|
||||
class WaterIntake(BaseModel):
|
||||
"""Модель для отслеживания потребления воды"""
|
||||
__tablename__ = "water_intake"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
|
||||
user_id = Column(Integer, nullable=False, index=True) # Связь с таблицей пользователей
|
||||
|
||||
entry_date = Column(Date, nullable=False, index=True)
|
||||
amount_ml = Column(Integer, nullable=False) # Количество в миллилитрах
|
||||
entry_time = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<WaterIntake user_id={self.user_id} date={self.entry_date} amount={self.amount_ml}ml>"
|
||||
|
||||
|
||||
class UserActivityEntry(BaseModel):
|
||||
"""Модель для отслеживания физической активности"""
|
||||
__tablename__ = "user_activity_entries"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
|
||||
user_id = Column(Integer, nullable=False, index=True) # Связь с таблицей пользователей
|
||||
|
||||
entry_date = Column(Date, nullable=False, index=True)
|
||||
activity_type = Column(String(100), nullable=False) # walking, running, yoga, etc.
|
||||
|
||||
duration_minutes = Column(Integer, nullable=False)
|
||||
calories_burned = Column(Float, nullable=True) # Расчетное количество сожженных калорий
|
||||
|
||||
# Дополнительные параметры активности
|
||||
distance_km = Column(Float, nullable=True) # Для активностей с расстоянием
|
||||
steps = Column(Integer, nullable=True) # Для ходьбы
|
||||
intensity = Column(String(20), nullable=True) # low, medium, high
|
||||
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserActivityEntry user_id={self.user_id} date={self.entry_date} activity={self.activity_type}>"
|
||||
|
||||
|
||||
class NutritionGoal(BaseModel):
|
||||
"""Модель для хранения целей пользователя по питанию и активности"""
|
||||
__tablename__ = "nutrition_goals"
|
||||
|
||||
user_id = Column(Integer, nullable=False, index=True, unique=True) # Связь с таблицей пользователей
|
||||
|
||||
# Цели по калориям и макронутриентам
|
||||
daily_calorie_goal = Column(Integer, nullable=True)
|
||||
protein_goal_grams = Column(Integer, nullable=True)
|
||||
fat_goal_grams = Column(Integer, nullable=True)
|
||||
carbs_goal_grams = Column(Integer, nullable=True)
|
||||
|
||||
# Цели по воде и активности
|
||||
water_goal_ml = Column(Integer, nullable=True, default=2000) # Стандартно 2 литра
|
||||
activity_goal_minutes = Column(Integer, nullable=True, default=30) # Минимум 30 минут активности
|
||||
|
||||
# Цель по весу и предпочтения
|
||||
weight_goal_kg = Column(Float, nullable=True)
|
||||
goal_type = Column(String(50), nullable=True) # lose_weight, maintain, gain_weight, health
|
||||
|
||||
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP(timezone=True), onupdate=func.now())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NutritionGoal user_id={self.user_id} calories={self.daily_calorie_goal}>"
|
||||
203
services/nutrition_service/schemas.py
Normal file
203
services/nutrition_service/schemas.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from datetime import date
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, root_validator
|
||||
|
||||
|
||||
class MealType(str, Enum):
|
||||
BREAKFAST = "breakfast"
|
||||
LUNCH = "lunch"
|
||||
DINNER = "dinner"
|
||||
SNACK = "snack"
|
||||
|
||||
|
||||
class ActivityIntensity(str, Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class GoalType(str, Enum):
|
||||
LOSE_WEIGHT = "lose_weight"
|
||||
MAINTAIN = "maintain"
|
||||
GAIN_WEIGHT = "gain_weight"
|
||||
HEALTH = "health"
|
||||
|
||||
|
||||
# Схемы для FoodItem
|
||||
class FoodItemBase(BaseModel):
|
||||
name: str
|
||||
brand: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
food_type: Optional[str] = None
|
||||
serving_size: Optional[str] = None
|
||||
serving_weight_grams: Optional[float] = None
|
||||
calories: Optional[float] = None
|
||||
protein_grams: Optional[float] = None
|
||||
fat_grams: Optional[float] = None
|
||||
carbs_grams: Optional[float] = None
|
||||
fiber_grams: Optional[float] = None
|
||||
sugar_grams: Optional[float] = None
|
||||
sodium_mg: Optional[float] = None
|
||||
cholesterol_mg: Optional[float] = None
|
||||
ingredients: Optional[str] = None
|
||||
|
||||
|
||||
class FoodItemCreate(FoodItemBase):
|
||||
fatsecret_id: Optional[str] = None
|
||||
is_verified: bool = False
|
||||
|
||||
|
||||
class FoodItemResponse(FoodItemBase):
|
||||
id: int
|
||||
uuid: str
|
||||
fatsecret_id: Optional[str] = None
|
||||
is_verified: bool
|
||||
created_at: str
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Схемы для UserNutritionEntry
|
||||
class UserNutritionEntryBase(BaseModel):
|
||||
entry_date: date
|
||||
meal_type: MealType
|
||||
quantity: float = Field(gt=0)
|
||||
unit: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class UserNutritionEntryCreate(UserNutritionEntryBase):
|
||||
food_item_id: Optional[int] = None
|
||||
custom_food_name: Optional[str] = None
|
||||
calories: Optional[float] = None
|
||||
protein_grams: Optional[float] = None
|
||||
fat_grams: Optional[float] = None
|
||||
carbs_grams: Optional[float] = None
|
||||
|
||||
@root_validator(skip_on_failure=True)
|
||||
def check_food_info(cls, values):
|
||||
food_item_id = values.get("food_item_id")
|
||||
custom_food_name = values.get("custom_food_name")
|
||||
|
||||
if food_item_id is None and not custom_food_name:
|
||||
raise ValueError("Either food_item_id or custom_food_name must be provided")
|
||||
return values
|
||||
|
||||
|
||||
class UserNutritionEntryResponse(UserNutritionEntryBase):
|
||||
id: int
|
||||
uuid: str
|
||||
user_id: int
|
||||
food_item_id: Optional[int] = None
|
||||
custom_food_name: Optional[str] = None
|
||||
calories: Optional[float] = None
|
||||
protein_grams: Optional[float] = None
|
||||
fat_grams: Optional[float] = None
|
||||
carbs_grams: Optional[float] = None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Схемы для WaterIntake
|
||||
class WaterIntakeBase(BaseModel):
|
||||
entry_date: date
|
||||
amount_ml: int = Field(gt=0)
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class WaterIntakeCreate(WaterIntakeBase):
|
||||
pass
|
||||
|
||||
|
||||
class WaterIntakeResponse(WaterIntakeBase):
|
||||
id: int
|
||||
uuid: str
|
||||
user_id: int
|
||||
entry_time: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Схемы для UserActivityEntry
|
||||
class UserActivityEntryBase(BaseModel):
|
||||
entry_date: date
|
||||
activity_type: str
|
||||
duration_minutes: int = Field(gt=0)
|
||||
distance_km: Optional[float] = None
|
||||
steps: Optional[int] = None
|
||||
intensity: Optional[ActivityIntensity] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class UserActivityEntryCreate(UserActivityEntryBase):
|
||||
calories_burned: Optional[float] = None
|
||||
|
||||
|
||||
class UserActivityEntryResponse(UserActivityEntryBase):
|
||||
id: int
|
||||
uuid: str
|
||||
user_id: int
|
||||
calories_burned: Optional[float] = None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Схемы для NutritionGoal
|
||||
class NutritionGoalBase(BaseModel):
|
||||
daily_calorie_goal: Optional[int] = None
|
||||
protein_goal_grams: Optional[int] = None
|
||||
fat_goal_grams: Optional[int] = None
|
||||
carbs_goal_grams: Optional[int] = None
|
||||
water_goal_ml: Optional[int] = None
|
||||
activity_goal_minutes: Optional[int] = None
|
||||
weight_goal_kg: Optional[float] = None
|
||||
goal_type: Optional[GoalType] = None
|
||||
|
||||
|
||||
class NutritionGoalCreate(NutritionGoalBase):
|
||||
pass
|
||||
|
||||
|
||||
class NutritionGoalResponse(NutritionGoalBase):
|
||||
id: int
|
||||
user_id: int
|
||||
created_at: str
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Схемы для запросов к FatSecret API
|
||||
class FoodSearchQuery(BaseModel):
|
||||
query: str
|
||||
page_number: int = 0
|
||||
max_results: int = 10
|
||||
|
||||
|
||||
class FoodDetailsQuery(BaseModel):
|
||||
food_id: str
|
||||
|
||||
|
||||
# Схемы для сводных данных
|
||||
class DailyNutritionSummary(BaseModel):
|
||||
date: date
|
||||
total_calories: float = 0
|
||||
total_protein_grams: float = 0
|
||||
total_fat_grams: float = 0
|
||||
total_carbs_grams: float = 0
|
||||
total_water_ml: int = 0
|
||||
total_activity_minutes: int = 0
|
||||
estimated_calories_burned: float = 0
|
||||
meals: List[UserNutritionEntryResponse] = []
|
||||
water_entries: List[WaterIntakeResponse] = []
|
||||
activity_entries: List[UserActivityEntryResponse] = []
|
||||
@@ -85,11 +85,6 @@ async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db
|
||||
try:
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
except ValueError as e:
|
||||
if "password cannot be longer than 72 bytes" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password is too long. Please use a shorter password (max 70 characters)."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Password validation error: {str(e)}"
|
||||
|
||||
@@ -41,15 +41,15 @@ class UserBase(BaseModel):
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=8, max_length=70, description="Password (will be truncated to 72 bytes for bcrypt compatibility)")
|
||||
password: str = Field(..., min_length=8, description="Password for user registration")
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password_bytes(cls, v):
|
||||
"""Ensure password doesn't exceed bcrypt's 72-byte limit."""
|
||||
password_bytes = v.encode('utf-8')
|
||||
if len(password_bytes) > 72:
|
||||
raise ValueError("Password is too long when encoded as UTF-8 (max 72 bytes for bcrypt)")
|
||||
"""Basic validation for password."""
|
||||
# Только проверка минимальной длины
|
||||
if not v or len(v.strip()) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
return v
|
||||
|
||||
|
||||
@@ -102,17 +102,15 @@ class UserResponse(UserBase):
|
||||
class UserLogin(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
username: Optional[str] = None
|
||||
password: str = Field(..., max_length=70, description="Password (will be truncated to 72 bytes for bcrypt compatibility)")
|
||||
password: str = Field(..., min_length=1, description="Password for authentication")
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password_bytes(cls, v):
|
||||
"""Ensure password doesn't exceed bcrypt's 72-byte limit."""
|
||||
"""Basic password validation."""
|
||||
if not v or len(v.strip()) == 0:
|
||||
raise ValueError("Password cannot be empty")
|
||||
password_bytes = v.encode('utf-8')
|
||||
if len(password_bytes) > 72:
|
||||
raise ValueError("Password is too long when encoded as UTF-8 (max 72 bytes for bcrypt)")
|
||||
# Не делаем проверку на максимальную длину - passlib/bcrypt сам справится с ограничениями
|
||||
return v
|
||||
|
||||
@field_validator("username")
|
||||
|
||||
Reference in New Issue
Block a user