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

@@ -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",
}

View File

View 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

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

View 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}>"

View 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] = []

View File

@@ -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)}"

View File

@@ -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")