Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Add saved recipes

Revision ID: af15d10833dd
Revises: ab61beb83573
Create Date: 2025-10-30 06:35:29.817586

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "af15d10833dd"
down_revision: Union[str, Sequence[str], None] = "ab61beb83573"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"saved_recipes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("recipe_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["recipe_id"],
["recipes.id"],
name=op.f("fk_saved_recipes_recipe_id_recipes"),
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
name=op.f("fk_saved_recipes_user_id_users"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_saved_recipes")),
sa.UniqueConstraint("user_id", "recipe_id", name="uq_user_recipe"),
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_table("saved_recipes")
2 changes: 2 additions & 0 deletions app/api/api_v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .messages import router as messages_router
from .products import router as products_router
from .recipes import router as recipes_router
from .saved_recipes import router as saved_recipes_router

http_bearer = HTTPBearer(auto_error=False)

Expand All @@ -21,3 +22,4 @@
router.include_router(messages_router)
router.include_router(products_router)
router.include_router(recipes_router)
router.include_router(saved_recipes_router)
2 changes: 2 additions & 0 deletions app/api/api_v1/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

def map_recipe_to_response(recipe: Recipe) -> RecipeResponse:
"""Преобразует объект модели Recipe в RecipeResponse"""

return RecipeResponse(
id=recipe.id,
title=recipe.title,
Expand All @@ -20,4 +21,5 @@ def map_recipe_to_response(recipe: Recipe) -> RecipeResponse:
],
total_calories=recipe.total_calories,
total_quantity=recipe.total_quantity,
is_saved=getattr(recipe, "is_saved", False),
)
27 changes: 21 additions & 6 deletions app/api/api_v1/recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from app.api.api_v1.fastapi_users import current_active_user_bearer
from app.api.api_v1.mixins import map_recipe_to_response
from app.crud import recipes
from app.crud import recipes, saved_recipes
from app.core.config import settings
from app.core.models import db_helper, User
from app.core.schemas import (
Expand All @@ -25,6 +25,7 @@ async def get_recipes_with_products(
session: AsyncSession = Depends(db_helper.session_getter),
):
"""Возвращает список рецептов с продуктами"""

recipe_list = await recipes.get_recipes_with_products(session=session)

return [map_recipe_to_response(recipe) for recipe in recipe_list]
Expand All @@ -41,6 +42,7 @@ async def create_recipe_with_products(
current_user: User = Depends(current_active_user_bearer),
):
"""Создает и возвращает рецепт с продуктами"""

try:
recipe = await recipes.create_recipe_with_products(
session=session,
Expand All @@ -61,14 +63,24 @@ async def create_recipe_with_products(
async def get_recipe_with_products_by_id(
recipe_id: int,
session: AsyncSession = Depends(db_helper.session_getter),
current_user: User = Depends(current_active_user_bearer),
) -> RecipeResponse:
"""Возвращает рецепт по id с продуктами"""

recipe = await recipes.get_recipe_with_products_by_id(
session=session,
recipe_id=recipe_id,
)
if recipe is None:
raise HTTPException(status_code=404, detail="Recipe not found")

is_saved = await saved_recipes.is_recipe_saved_by_user(
session=session,
user_id=current_user.id,
recipe_id=recipe_id,
)
recipe.is_saved = is_saved

return map_recipe_to_response(recipe)


Expand All @@ -80,6 +92,7 @@ async def update_recipe_with_products(
current_user: User = Depends(current_active_user_bearer),
):
"""Обновляет рецепт по id с продуктами"""

try:
recipe = await recipes.update_recipe_with_products(
session=session,
Expand Down Expand Up @@ -111,6 +124,7 @@ async def partial_update_recipe(
current_user: User = Depends(current_active_user_bearer),
):
"""Частично обновляет рецепт по id"""

try:
updated_recipe = await recipes.partial_update_recipe_with_products(
session=session,
Expand Down Expand Up @@ -149,6 +163,7 @@ async def delete_recipe(
current_user: User = Depends(current_active_user_bearer),
):
"""Удаляет рецепт по id"""

try:
await recipes.delete_recipe(
session=session,
Expand All @@ -169,14 +184,14 @@ async def delete_recipe(

@router.post("/upload/recipe-image")
async def upload_recipe_image(file: UploadFile = File(...)):
# Проверяем тип файла
# Проверка тип файла
if not file.content_type.startswith("image/"):
raise HTTPException(
status_code=400,
detail="Файл должен быть изображением",
)

# Проверяем размер файла (максимум 5MB)
# Проверка размера файла (максимум 5MB)
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
Expand All @@ -187,20 +202,20 @@ async def upload_recipe_image(file: UploadFile = File(...)):
detail="Файл слишком большой. Максимальный размер: 5MB",
)

# Генерируем уникальное имя файла
# Генерация уникального имени файла
file_extension = (
file.filename.split(".")[-1] if "." in file.filename else "jpg"
) # noqa: E501
filename = f"{uuid.uuid4()}.{file_extension}"
file_path = f"static/uploads/recipes/{filename}"

try:
# Сохраняем файл
# Сохранение файла
with open(file_path, "wb") as buffer:
content = await file.read()
buffer.write(content)

# Возвращаем URL для доступа к файлу
# Возвращение URL для доступа к файлу
image_url = f"/static/uploads/recipes/{filename}"
return {"image_url": image_url}

Expand Down
101 changes: 101 additions & 0 deletions app/api/api_v1/saved_recipes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.api_v1.fastapi_users import current_active_user_bearer
from app.api.api_v1.mixins import map_recipe_to_response
from app.crud import saved_recipes, recipes
from app.core.config import settings
from app.core.models import db_helper, User
from app.core.schemas import RecipeResponse, SaveRecipeRequest

router = APIRouter(
prefix=settings.api.v1.saved_recipes,
tags=["Saved Recipes"],
)


@router.get("/", response_model=List[RecipeResponse])
async def get_my_saved_recipes(
session: AsyncSession = Depends(db_helper.session_getter),
current_user: User = Depends(current_active_user_bearer),
):
"""Возвращает сохраненные рецепты текущего пользователя"""

saved_recipes_list = await saved_recipes.get_saved_recipes(
session=session,
user_id=current_user.id,
)

return [
map_recipe_to_response(saved_recipe.recipe)
for saved_recipe in saved_recipes_list
]


@router.post(
"/",
response_model=RecipeResponse,
status_code=status.HTTP_201_CREATED,
)
async def save_recipe(
save_request: SaveRecipeRequest,
session: AsyncSession = Depends(db_helper.session_getter),
current_user: User = Depends(current_active_user_bearer),
):
"""Сохраняет рецепт для текущего пользователя"""

try:
await saved_recipes.save_recipe(
session=session,
user_id=current_user.id,
recipe_id=save_request.recipe_id,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

# Получаем полную информацию о рецепте
recipe = await recipes.get_recipe_with_products_by_id(
session=session,
recipe_id=save_request.recipe_id,
)

return map_recipe_to_response(recipe)


@router.delete(
"/{recipe_id}/",
status_code=status.HTTP_204_NO_CONTENT,
)
async def unsave_recipe(
recipe_id: int,
session: AsyncSession = Depends(db_helper.session_getter),
current_user: User = Depends(current_active_user_bearer),
):
"""Удаляет рецепт из сохраненных"""

try:
await saved_recipes.unsave_recipe(
session=session,
user_id=current_user.id,
recipe_id=recipe_id,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))


@router.get("/check/{recipe_id}/")
async def check_recipe_saved(
recipe_id: int,
session: AsyncSession = Depends(db_helper.session_getter),
current_user: User = Depends(current_active_user_bearer),
):
"""Проверяет, сохранен ли рецепт пользователем"""

is_saved = await saved_recipes.is_recipe_saved_by_user(
session=session,
user_id=current_user.id,
recipe_id=recipe_id,
)

return {"is_saved": is_saved}
1 change: 1 addition & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ApiV1Prefix(BaseModel):
messages: str = "/messages"
products: str = "/products"
recipes: str = "/recipes"
saved_recipes: str = "/saved_recipes"


class ApiPrefix(BaseModel):
Expand Down
2 changes: 2 additions & 0 deletions app/core/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"Product",
"Recipe",
"RecipeProductAssociation",
"SavedRecipe",
)

from .access_token import AccessToken
Expand All @@ -17,3 +18,4 @@
from .product import Product
from .recipe import Recipe
from .recipe_product_association import RecipeProductAssociation
from .saved_recipe import SavedRecipe
22 changes: 15 additions & 7 deletions app/core/models/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .user import User
from .product import Product
from .recipe_product_association import RecipeProductAssociation
from .saved_recipe import SavedRecipe


class Recipe(Base, IdIntPkMixin):
Expand All @@ -22,14 +23,14 @@ class Recipe(Base, IdIntPkMixin):
server_default="",
)
image_url: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True,
default=None
String(500), nullable=True, default=None
)
product_associations: Mapped[list["RecipeProductAssociation"]] = relationship(
"RecipeProductAssociation",
back_populates="recipe",
cascade="all, delete-orphan",
product_associations: Mapped[list["RecipeProductAssociation"]] = (
relationship( # noqa: E501
"RecipeProductAssociation",
back_populates="recipe",
cascade="all, delete-orphan",
)
)
products: Mapped[list["Product"]] = relationship(
secondary="recipe_product_association",
Expand All @@ -42,6 +43,13 @@ class Recipe(Base, IdIntPkMixin):
)
user: Mapped["User"] = relationship(back_populates="recipes")

# Пользователи, сохранившие рецепт
saved_by_users: Mapped[list["SavedRecipe"]] = relationship(
"SavedRecipe",
back_populates="recipe",
cascade="all, delete-orphan",
)

@property
def total_quantity(self) -> int:
"""Возвращает суммарное количество (в граммах) всех продуктов"""
Expand Down
28 changes: 28 additions & 0 deletions app/core/models/saved_recipe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from typing import TYPE_CHECKING

from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship

from . import Base

if TYPE_CHECKING:
from .user import User
from .recipe import Recipe


class SavedRecipe(Base):
__tablename__ = "saved_recipes"
__table_args__ = (
UniqueConstraint(
"user_id",
"recipe_id",
name="uq_user_recipe",
),
)

id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
recipe_id: Mapped[int] = mapped_column(ForeignKey("recipes.id"))

user: Mapped["User"] = relationship(back_populates="saved_recipes")
recipe: Mapped["Recipe"] = relationship(back_populates="saved_by_users")
Loading