From 6cb72fbbfc6b2083edae8c2fca7a9057953d458b Mon Sep 17 00:00:00 2001 From: earlinn Date: Fri, 22 Dec 2023 19:35:05 +0600 Subject: [PATCH 1/9] Add more fields in recipe serializer, fix bugs of export_data command and search bugs on Admin panel --- backend/api/recipes_serializers.py | 36 ++++++++++++------- backend/products/admin.py | 4 +-- .../management/commands/export_data.py | 4 +-- backend/reviews/admin.py | 2 +- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/backend/api/recipes_serializers.py b/backend/api/recipes_serializers.py index b1dcb887..8c8baf33 100644 --- a/backend/api/recipes_serializers.py +++ b/backend/api/recipes_serializers.py @@ -1,3 +1,5 @@ +from math import ceil + from rest_framework import serializers from recipes.models import ProductsInRecipe, Recipe @@ -12,12 +14,28 @@ class ProductsInRecipeSerializer(serializers.ModelSerializer): id = serializers.ReadOnlyField(source="ingredient.id") name = serializers.ReadOnlyField(source="ingredient.name") measure_unit = serializers.ReadOnlyField(source="ingredient.measure_unit") + amount = serializers.ReadOnlyField(source="ingredient.amount") + final_price = serializers.ReadOnlyField(source="ingredient.final_price") ingredient_photo = serializers.ImageField(source="ingredient.photo") - quantity = serializers.ReadOnlyField(source="amount") + quantity_in_recipe = serializers.ReadOnlyField(source="amount") + need_to_buy = serializers.SerializerMethodField() class Meta: model = ProductsInRecipe - fields = ("id", "name", "measure_unit", "ingredient_photo", "quantity") + fields = ( + "id", + "name", + "measure_unit", + "amount", + "final_price", + "ingredient_photo", + "quantity_in_recipe", + "need_to_buy", + ) + + def get_need_to_buy(self, obj): + """Calculates the number of product units to buy for this recipe.""" + return ceil(obj.amount / obj.ingredient.amount) class RecipeSerializer(serializers.ModelSerializer): @@ -50,16 +68,10 @@ def get_recipe_nutrients(self, obj): carbohydrates = 0 for ingredient in obj.ingredients.all(): - proteins += ( - ingredient.proteins * ingredient.productsinrecipe.get(recipe=obj).amount - ) / 100 - fats += ( - ingredient.fats * ingredient.productsinrecipe.get(recipe=obj).amount - ) / 100 - carbohydrates += ( - ingredient.carbohydrates - * ingredient.productsinrecipe.get(recipe=obj).amount - ) / 100 + amount = ingredient.productsinrecipe.get(recipe=obj).amount + proteins += (ingredient.proteins * amount) / 100 + fats += (ingredient.fats * amount) / 100 + carbohydrates += (ingredient.carbohydrates * ingredient.amount) / 100 kcal = proteins * 4 + fats * 9 + carbohydrates * 4 return { diff --git a/backend/products/admin.py b/backend/products/admin.py index 32e28fdf..d3beb302 100644 --- a/backend/products/admin.py +++ b/backend/products/admin.py @@ -184,7 +184,7 @@ class ProductAdmin(admin.ModelAdmin): "orders_number", "photo", ] - search_fields = ["name", "description", "producer"] + search_fields = ["name", "description", "producer__name"] readonly_fields = ["creation_time", "promotion_quantity", "final_price", "rating"] ordering = ["pk"] list_filter = [ @@ -228,5 +228,5 @@ class FavoriteProductAdmin(admin.ModelAdmin): list_display = ["pk", "product", "user"] list_display_links = ("product",) fields = ["user", "product"] - search_fields = ["user", "product"] + search_fields = ["user__username", "product__name"] list_filter = ["product"] diff --git a/backend/products/management/commands/export_data.py b/backend/products/management/commands/export_data.py index 96af0e0c..1b34cb4f 100644 --- a/backend/products/management/commands/export_data.py +++ b/backend/products/management/commands/export_data.py @@ -52,9 +52,9 @@ def export_products_components(): writer.writerow(field_names) id = 0 for product in products: - id += 1 components = product.components.all() for component in components: + id += 1 data = [id, product.pk, component.pk] writer.writerow(data) @@ -68,9 +68,9 @@ def export_products_tags(): writer.writerow(field_names) id = 0 for product in products: - id += 1 tags = product.tags.all() for tag in tags: + id += 1 data = [id, product.pk, tag.pk] writer.writerow(data) diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index 530acb37..597a29e7 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -19,6 +19,6 @@ class ReviewAdmin(admin.ModelAdmin): list_display_links = ("product",) fields = ["product", "author", "score", "pub_date", "was_edited", "text"] readonly_fields = ["pub_date", "was_edited"] - search_fields = ["product", "author", "text"] + search_fields = ["product__name", "author__username", "text"] list_filter = ["score", "pub_date", "product", "author", "was_edited"] ordering = ["pk"] From 2ee4644974f6371f6d1412d38c5216f388259930 Mon Sep 17 00:00:00 2001 From: earlinn Date: Fri, 22 Dec 2023 22:44:38 +0600 Subject: [PATCH 2/9] Fix field types of recipes endpoints in api docs --- backend/api/recipes_serializers.py | 51 ++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/backend/api/recipes_serializers.py b/backend/api/recipes_serializers.py index 8c8baf33..30d1d3e8 100644 --- a/backend/api/recipes_serializers.py +++ b/backend/api/recipes_serializers.py @@ -1,5 +1,6 @@ from math import ceil +from drf_yasg import openapi from rest_framework import serializers from recipes.models import ProductsInRecipe, Recipe @@ -32,8 +33,52 @@ class Meta: "quantity_in_recipe", "need_to_buy", ) + swagger_schema_fields = { + "type": openapi.TYPE_OBJECT, + "properties": { + "id": openapi.Schema( + title="Id", type=openapi.TYPE_INTEGER, read_only=True + ), + "name": openapi.Schema( + title="Name", + type=openapi.TYPE_STRING, + read_only=True, + ), + "measure_unit": openapi.Schema( + title="Measure unit", + type=openapi.TYPE_STRING, + read_only=True, + ), + "amount": openapi.Schema( + title="Amount", + type=openapi.TYPE_INTEGER, + read_only=True, + ), + "final_price": openapi.Schema( + title="Final price", + type=openapi.TYPE_INTEGER, + read_only=True, + ), + "ingredient_photo": openapi.Schema( + title="Ingredient photo", + type=openapi.TYPE_STRING, + format=openapi.FORMAT_URI, + read_only=True, + ), + "quantity_in_recipe": openapi.Schema( + title="Quantity in recipe", + type=openapi.TYPE_INTEGER, + read_only=True, + ), + "need_to_buy": openapi.Schema( + title="Need to buy", + type=openapi.TYPE_INTEGER, + read_only=True, + ), + }, + } - def get_need_to_buy(self, obj): + def get_need_to_buy(self, obj) -> int: """Calculates the number of product units to buy for this recipe.""" return ceil(obj.amount / obj.ingredient.amount) @@ -59,10 +104,10 @@ class Meta: "cooking_time", ) - def get_total_ingredients(self, obj): + def get_total_ingredients(self, obj) -> int: return obj.ingredients.count() - def get_recipe_nutrients(self, obj): + def get_recipe_nutrients(self, obj) -> dict[str, float]: proteins = 0 fats = 0 carbohydrates = 0 From 4d2cb6bc5b3d2b534122f8792543cba019662130 Mon Sep 17 00:00:00 2001 From: earlinn Date: Sat, 23 Dec 2023 00:37:00 +0600 Subject: [PATCH 3/9] Fix type of serializers fields in api docs --- backend/api/products_serializers.py | 108 ++++++++++++++-------------- backend/api/recipes_serializers.py | 11 ++- 2 files changed, 64 insertions(+), 55 deletions(-) diff --git a/backend/api/products_serializers.py b/backend/api/products_serializers.py index 20590d99..42dad193 100644 --- a/backend/api/products_serializers.py +++ b/backend/api/products_serializers.py @@ -1,5 +1,6 @@ from django.db.models import Avg from drf_spectacular.utils import extend_schema_field +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from .users_serializers import UserLightSerializer @@ -23,7 +24,7 @@ class SubcategoryLightSerializer(serializers.ModelSerializer): """Serializer for subcategories representation in product serializer.""" subcategory_name = serializers.CharField(source="name") - subcategory_slug = serializers.CharField(source="slug") + subcategory_slug = serializers.SlugField(source="slug") class Meta: model = Subcategory @@ -41,7 +42,7 @@ class CategoryLightSerializer(serializers.ModelSerializer): """Serializer for categories representation in product serializer.""" category_name = serializers.CharField(source="name") - category_slug = serializers.CharField(source="slug") + category_slug = serializers.SlugField(source="slug") class Meta: model = Category @@ -56,63 +57,22 @@ class Meta: fields = ("id", "category_name", "slug", "image") -class CategorySerializer(CategoryLightSerializer): - """Serializer for displaying categories.""" - - subcategories = SubcategoryLightSerializer(many=True, required=False) - top_three_products = serializers.SerializerMethodField() - - class Meta(CategoryLightSerializer.Meta): - fields = ("id", "name", "slug", "image", "subcategories", "top_three_products") - - def get_top_three_products(self, obj): - """Shows three most popular products of a particular category.""" - top_three_products_queryset = ( - obj.products.select_related("category", "subcategory", "producer") - .prefetch_related("components", "tags", "promotions", "reviews") - .order_by("-orders_number")[:3] - ) - return ProductSerializer( - top_three_products_queryset, many=True, context=self.context - ).data - - class TagLightSerializer(serializers.ModelSerializer): """Serializer for tags representation in product serializer.""" tag_name = serializers.CharField(source="name") - tag_slug = serializers.CharField(source="slug") + tag_slug = serializers.SlugField(source="slug") class Meta: model = Tag fields = ("tag_name", "tag_slug") -class TagSerializer(TagLightSerializer): - """Serializer for tags representation.""" - - top_three_products = serializers.SerializerMethodField() - - class Meta(TagLightSerializer.Meta): - fields = ("id", "name", "slug", "image", "top_three_products") - - def get_top_three_products(self, obj): - """Shows three most popular products of a particular tag.""" - top_three_products_queryset = ( - obj.products.select_related("category", "subcategory", "producer") - .prefetch_related("components", "tags", "promotions", "reviews") - .order_by("-orders_number")[:3] - ) - return ProductSerializer( - top_three_products_queryset, context=self.context, many=True - ).data - - class ComponentLightSerializer(serializers.ModelSerializer): """Serializer for components representation in product serializer.""" component_name = serializers.CharField(source="name") - component_slug = serializers.CharField(source="slug") + component_slug = serializers.SlugField(source="slug") class Meta: model = Component @@ -130,7 +90,7 @@ class ProducerLightSerializer(serializers.ModelSerializer): """Serializer for produsers representation in product serializer.""" producer_name = serializers.CharField(source="name") - producer_slug = serializers.CharField(source="slug") + producer_slug = serializers.SlugField(source="slug") class Meta: model = Producer @@ -156,7 +116,7 @@ class PromotionLightSerializer(serializers.ModelSerializer): """Serializer for promotions representation in product serializer.""" promotion_name = serializers.CharField(source="name") - promotion_slug = serializers.CharField(source="slug") + promotion_slug = serializers.SlugField(source="slug") class Meta: model = Promotion @@ -234,21 +194,22 @@ class Meta: ) @extend_schema_field(bool) - def get_is_favorited(self, obj): + def get_is_favorited(self, obj) -> bool: request = self.context.get("request") if not request or request.user.is_anonymous: return False return obj.is_favorited(request.user) @extend_schema_field(int) - def get_promotion_quantity(self, obj): + def get_promotion_quantity(self, obj) -> int: return obj.promotions.count() @extend_schema_field(float) - def get_final_price(self, obj): + def get_final_price(self, obj) -> float: return obj.final_price - def get_rating(self, obj): + # TODO: make annotate + def get_rating(self, obj) -> float: product_reviews = obj.reviews.all() if product_reviews: return round(product_reviews.aggregate(Avg("score"))["score__avg"], 1) @@ -361,7 +322,7 @@ class Meta: ) @extend_schema_field(float) - def get_final_price(self, obj): + def get_final_price(self, obj) -> float: return obj.final_price @@ -389,3 +350,46 @@ class FavoriteProductCreateSerializer(serializers.ModelSerializer): class Meta: model = FavoriteProduct fields = ("id",) + + +class CategorySerializer(CategoryLightSerializer): + """Serializer for displaying categories.""" + + subcategories = SubcategoryLightSerializer(many=True, required=False) + top_three_products = serializers.SerializerMethodField() + + class Meta(CategoryLightSerializer.Meta): + fields = ("id", "name", "slug", "image", "subcategories", "top_three_products") + + @swagger_serializer_method(serializer_or_field=ProductSerializer(many=True)) + def get_top_three_products(self, obj): + """Shows three most popular products of a particular category.""" + top_three_products_queryset = ( + obj.products.select_related("category", "subcategory", "producer") + .prefetch_related("components", "tags", "promotions", "reviews") + .order_by("-orders_number")[:3] + ) + return ProductSerializer( + top_three_products_queryset, many=True, context=self.context + ).data + + +class TagSerializer(TagLightSerializer): + """Serializer for tags representation.""" + + top_three_products = serializers.SerializerMethodField() + + class Meta(TagLightSerializer.Meta): + fields = ("id", "name", "slug", "image", "top_three_products") + + @swagger_serializer_method(serializer_or_field=ProductSerializer(many=True)) + def get_top_three_products(self, obj): + """Shows three most popular products of a particular tag.""" + top_three_products_queryset = ( + obj.products.select_related("category", "subcategory", "producer") + .prefetch_related("components", "tags", "promotions", "reviews") + .order_by("-orders_number")[:3] + ) + return ProductSerializer( + top_three_products_queryset, context=self.context, many=True + ).data diff --git a/backend/api/recipes_serializers.py b/backend/api/recipes_serializers.py index 30d1d3e8..6b2c2861 100644 --- a/backend/api/recipes_serializers.py +++ b/backend/api/recipes_serializers.py @@ -3,6 +3,7 @@ from drf_yasg import openapi from rest_framework import serializers +from products.models import Product from recipes.models import ProductsInRecipe, Recipe RECIPE_KCAL_DECIMAL_PLACES = 0 @@ -37,7 +38,7 @@ class Meta: "type": openapi.TYPE_OBJECT, "properties": { "id": openapi.Schema( - title="Id", type=openapi.TYPE_INTEGER, read_only=True + title="ID", type=openapi.TYPE_INTEGER, read_only=True ), "name": openapi.Schema( title="Name", @@ -46,7 +47,11 @@ class Meta: ), "measure_unit": openapi.Schema( title="Measure unit", - type=openapi.TYPE_STRING, + type=openapi.TYPE_ARRAY, + items=openapi.Items( + enum=[Product.GRAMS, Product.MILLILITRES, Product.ITEMS], + type=openapi.TYPE_STRING, + ), read_only=True, ), "amount": openapi.Schema( @@ -56,7 +61,7 @@ class Meta: ), "final_price": openapi.Schema( title="Final price", - type=openapi.TYPE_INTEGER, + type=openapi.TYPE_NUMBER, read_only=True, ), "ingredient_photo": openapi.Schema( From 090d484415299cbadbfddec007b665b371b10239 Mon Sep 17 00:00:00 2001 From: earlinn Date: Sat, 23 Dec 2023 01:08:28 +0600 Subject: [PATCH 4/9] Make the annotated rating field in Product and round it in SerializerMethodField --- backend/api/products_serializers.py | 14 +++++++------- backend/api/products_views.py | 17 +++++++++++------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/backend/api/products_serializers.py b/backend/api/products_serializers.py index 42dad193..b2c1e916 100644 --- a/backend/api/products_serializers.py +++ b/backend/api/products_serializers.py @@ -1,4 +1,3 @@ -from django.db.models import Avg from drf_spectacular.utils import extend_schema_field from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -17,7 +16,7 @@ Tag, ) -NO_RATING_MESSAGE = "У данного продукта еще нет оценок." +RATING_DECIMAL_PLACES = 1 class SubcategoryLightSerializer(serializers.ModelSerializer): @@ -208,12 +207,13 @@ def get_promotion_quantity(self, obj) -> int: def get_final_price(self, obj) -> float: return obj.final_price - # TODO: make annotate def get_rating(self, obj) -> float: - product_reviews = obj.reviews.all() - if product_reviews: - return round(product_reviews.aggregate(Avg("score"))["score__avg"], 1) - return NO_RATING_MESSAGE + """Rounds and returns the annotated rating value.""" + return ( + round(obj.rating, RATING_DECIMAL_PLACES) + if obj.rating is not None + else obj.rating + ) class ProductCreateSerializer(ProductSerializer): diff --git a/backend/api/products_views.py b/backend/api/products_views.py index f731b819..e340a63a 100644 --- a/backend/api/products_views.py +++ b/backend/api/products_views.py @@ -1,4 +1,5 @@ from django.db import transaction +from django.db.models import Avg from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django_filters import rest_framework as rf_filters @@ -504,9 +505,11 @@ class ProductViewSet(DestroyWithPayloadMixin, viewsets.ModelViewSet): """Viewset for products.""" http_method_names = ["get", "post", "patch", "delete"] - queryset = Product.objects.select_related( - "category", "subcategory", "producer" - ).prefetch_related("components", "tags", "promotions", "reviews") + queryset = ( + Product.objects.select_related("category", "subcategory", "producer") + .prefetch_related("components", "tags", "promotions", "reviews") + .annotate(rating=Avg("reviews__score")) + ) serializer_class = ProductSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [rf_filters.DjangoFilterBackend] @@ -523,9 +526,11 @@ def get_serializer_class(self): return ProductSerializer def get_queryset(self): - return Product.objects.select_related( - "category", "subcategory", "producer" - ).prefetch_related("components", "tags", "promotions", "reviews") + return ( + Product.objects.select_related("category", "subcategory", "producer") + .prefetch_related("components", "tags", "promotions", "reviews") + .annotate(rating=Avg("reviews__score")) + ) @transaction.atomic def create_delete_or_scold(self, model, product, request): From 8114dce7739c792bbd7887084408fa8eccf74a92 Mon Sep 17 00:00:00 2001 From: earlinn Date: Sat, 23 Dec 2023 12:52:40 +0600 Subject: [PATCH 5/9] Add order filter to ProductViewSet as products order changed after adding of rating annotated field, update promotion filter tests --- backend/api/products_views.py | 5 +++-- backend/tests/api_tests/test_product_filters.py | 6 ++---- backend/tests/fixtures.py | 8 +++++++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/backend/api/products_views.py b/backend/api/products_views.py index e340a63a..9ab15577 100644 --- a/backend/api/products_views.py +++ b/backend/api/products_views.py @@ -10,7 +10,7 @@ ValidationErrorResponseSerializer, ) from drf_yasg.utils import swagger_auto_schema -from rest_framework import decorators, permissions, response, status, viewsets +from rest_framework import decorators, filters, permissions, response, status, viewsets from .filters import ProductFilter from .mixins import MESSAGE_ON_DELETE, DestroyWithPayloadMixin @@ -512,8 +512,9 @@ class ProductViewSet(DestroyWithPayloadMixin, viewsets.ModelViewSet): ) serializer_class = ProductSerializer permission_classes = [IsAdminOrReadOnly] - filter_backends = [rf_filters.DjangoFilterBackend] + filter_backends = [rf_filters.DjangoFilterBackend, filters.OrderingFilter] filterset_class = ProductFilter + ordering = ["pk"] pagination_class = CustomPageNumberPagination def get_serializer_class(self): diff --git a/backend/tests/api_tests/test_product_filters.py b/backend/tests/api_tests/test_product_filters.py index efdf086c..941757ba 100644 --- a/backend/tests/api_tests/test_product_filters.py +++ b/backend/tests/api_tests/test_product_filters.py @@ -241,11 +241,10 @@ def test_product_tags_filter_fail_invalid_slug(client, tags): assert response.data["errors"][0]["attr"] == "tags" -@pytest.mark.skip(reason="Update it as the filter was changed") @pytest.mark.django_db def test_product_promotions_filter(client, products, promotions): products[1].promotions.set([promotions[0]]) - filter = f"?promotions={promotions[0].pk}" + filter = f"?promotions={promotions[0].slug}" response = client.get(reverse("api:product-list") + filter) assert response.status_code == 200 @@ -258,12 +257,11 @@ def test_product_promotions_filter(client, products, promotions): ) -@pytest.mark.skip(reason="Update it as the filter was changed") @pytest.mark.django_db def test_product_promotions_filter_multiple(client, products, promotions): products[0].promotions.set([promotions[1]]) products[1].promotions.set([promotions[0]]) - filter = f"?promotions={promotions[0].pk}&promotions={promotions[1].pk}" + filter = f"?promotions={promotions[0].slug}&promotions={promotions[1].slug}" response = client.get(reverse("api:product-list") + filter) assert response.status_code == 200 diff --git a/backend/tests/fixtures.py b/backend/tests/fixtures.py index c1799e1b..b46dc06d 100644 --- a/backend/tests/fixtures.py +++ b/backend/tests/fixtures.py @@ -94,6 +94,9 @@ PROMOTION_NAME_1 = "Birthday Discount 15%" PROMOTION_NAME_2 = "Black Friday" +PROMOTION_SLUG_1 = "birthday-15" +PROMOTION_SLUG_2 = "black-friday" + PROMOTION_DISCOUNT_1 = 15 PROMOTION_DISCOUNT_2 = 20 @@ -214,9 +217,12 @@ def promotions(): Promotion.objects.create( promotion_type=Promotion.BIRTHDAY, name=PROMOTION_NAME_1, + slug=PROMOTION_SLUG_1, discount=PROMOTION_DISCOUNT_1, ) - Promotion.objects.create(name=PROMOTION_NAME_2, discount=PROMOTION_DISCOUNT_2) + Promotion.objects.create( + name=PROMOTION_NAME_2, slug=PROMOTION_SLUG_2, discount=PROMOTION_DISCOUNT_2 + ) return Promotion.objects.all() From 3adb83a356b7ce2621e572235224d65d584b33ac Mon Sep 17 00:00:00 2001 From: earlinn Date: Sat, 23 Dec 2023 21:25:59 +0600 Subject: [PATCH 6/9] Add setup_eager_loading method to ProductSerializer to reduce DB trips during listing of products, categories and tags --- backend/api/products_serializers.py | 51 ++++++++++++++++++++++++----- backend/api/products_views.py | 16 ++++----- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/backend/api/products_serializers.py b/backend/api/products_serializers.py index b2c1e916..dc97d6b8 100644 --- a/backend/api/products_serializers.py +++ b/backend/api/products_serializers.py @@ -1,3 +1,4 @@ +from django.db.models import Avg, Exists, OuterRef from drf_spectacular.utils import extend_schema_field from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -192,12 +193,46 @@ class Meta: "is_favorited", ) + @classmethod + def setup_eager_loading(cls, queryset, user): + """Perform necessary eager loading of product data.""" + if user.is_anonymous: + queryset = ( + queryset.select_related("category", "subcategory", "producer") + .prefetch_related( + "components", + "tags", + "promotions", + "reviews", + ) + .annotate(rating=Avg("reviews__score")) + ) + else: + queryset = ( + queryset.select_related("category", "subcategory", "producer") + .prefetch_related( + "components", + "tags", + "promotions", + "reviews", + ) + .annotate( + rating=Avg("reviews__score"), + favorited=Exists( + FavoriteProduct.objects.filter( + user=user, product=OuterRef("id") + ) + ), + ) + ) + return queryset + @extend_schema_field(bool) def get_is_favorited(self, obj) -> bool: request = self.context.get("request") if not request or request.user.is_anonymous: return False - return obj.is_favorited(request.user) + return obj.favorited @extend_schema_field(int) def get_promotion_quantity(self, obj) -> int: @@ -364,10 +399,9 @@ class Meta(CategoryLightSerializer.Meta): @swagger_serializer_method(serializer_or_field=ProductSerializer(many=True)) def get_top_three_products(self, obj): """Shows three most popular products of a particular category.""" - top_three_products_queryset = ( - obj.products.select_related("category", "subcategory", "producer") - .prefetch_related("components", "tags", "promotions", "reviews") - .order_by("-orders_number")[:3] + top_three_products_queryset = ProductSerializer.setup_eager_loading( + obj.products.order_by("-orders_number")[:3], + self.context.get("request").user, ) return ProductSerializer( top_three_products_queryset, many=True, context=self.context @@ -385,10 +419,9 @@ class Meta(TagLightSerializer.Meta): @swagger_serializer_method(serializer_or_field=ProductSerializer(many=True)) def get_top_three_products(self, obj): """Shows three most popular products of a particular tag.""" - top_three_products_queryset = ( - obj.products.select_related("category", "subcategory", "producer") - .prefetch_related("components", "tags", "promotions", "reviews") - .order_by("-orders_number")[:3] + top_three_products_queryset = ProductSerializer.setup_eager_loading( + obj.products.order_by("-orders_number")[:3], + self.context.get("request").user, ) return ProductSerializer( top_three_products_queryset, context=self.context, many=True diff --git a/backend/api/products_views.py b/backend/api/products_views.py index 9ab15577..5ce61479 100644 --- a/backend/api/products_views.py +++ b/backend/api/products_views.py @@ -1,5 +1,4 @@ from django.db import transaction -from django.db.models import Avg from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django_filters import rest_framework as rf_filters @@ -505,11 +504,7 @@ class ProductViewSet(DestroyWithPayloadMixin, viewsets.ModelViewSet): """Viewset for products.""" http_method_names = ["get", "post", "patch", "delete"] - queryset = ( - Product.objects.select_related("category", "subcategory", "producer") - .prefetch_related("components", "tags", "promotions", "reviews") - .annotate(rating=Avg("reviews__score")) - ) + queryset = Product.objects.all() serializer_class = ProductSerializer permission_classes = [IsAdminOrReadOnly] filter_backends = [rf_filters.DjangoFilterBackend, filters.OrderingFilter] @@ -528,9 +523,12 @@ def get_serializer_class(self): def get_queryset(self): return ( - Product.objects.select_related("category", "subcategory", "producer") - .prefetch_related("components", "tags", "promotions", "reviews") - .annotate(rating=Avg("reviews__score")) + ProductSerializer.setup_eager_loading( + Product.objects.all(), self.request.user + ) + # Product.objects.select_related("category", "subcategory", "producer") + # .prefetch_related("components", "tags", "promotions", "reviews") + # .annotate(rating=Avg("reviews__score")) ) @transaction.atomic From 695c524926894a6b5002419c2f30c11b57770683 Mon Sep 17 00:00:00 2001 From: earlinn Date: Mon, 25 Dec 2023 17:26:54 +0600 Subject: [PATCH 7/9] Try to reduce DB trips when displaying the user's order history --- backend/api/orders_serializers.py | 19 +++++++++++++++++++ backend/api/orders_views.py | 16 +++------------- backend/api/products_serializers.py | 2 +- backend/api/products_views.py | 9 ++------- backend/api/recipes_serializers.py | 1 + 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/backend/api/orders_serializers.py b/backend/api/orders_serializers.py index 190950b1..a598e75d 100644 --- a/backend/api/orders_serializers.py +++ b/backend/api/orders_serializers.py @@ -92,6 +92,25 @@ class Meta: ) model = Order + # TODO: commented code doesn't reduce DB hits + @classmethod + def setup_eager_loading(cls, queryset): + """Perform necessary eager loading of orders data.""" + return queryset.select_related("user") + # ).prefetch_related( + # Prefetch( + # "products", + # queryset=Product.objects.select_related( + # "category", "subcategory", "producer" + # ).prefetch_related( + # "components", + # "tags", + # "promotions", + # "reviews", + # ), + # ) + # ) + class OrderGetAnonSerializer(serializers.ModelSerializer): """Serializer for anonimous user order representation.""" diff --git a/backend/api/orders_views.py b/backend/api/orders_views.py index cdaba4e9..32c8f220 100644 --- a/backend/api/orders_views.py +++ b/backend/api/orders_views.py @@ -2,7 +2,6 @@ from django.conf import settings from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import PermissionDenied -from django.db.models import Prefetch from django.utils.decorators import method_decorator from drf_standardized_errors.openapi_serializers import ( ErrorResponse401Serializer, @@ -209,7 +208,7 @@ class OrderViewSet( ): """Viewset for Order.""" - queryset = Order.objects.all() + queryset = OrderGetAuthSerializer.setup_eager_loading(Order.objects.all()) permission_classes = [AllowAny] def get_serializer_class(self): @@ -267,17 +266,8 @@ def retrieve(self, request, **kwargs): def list(self, request, **kwargs): if self.request.user.is_authenticated: - queryset = ( - Order.objects.select_related("user", "address", "delivery_point") - .prefetch_related( - Prefetch( - "products", - queryset=Product.objects.prefetch_related("promotions"), - ) - ) - .filter(user=self.request.user) - ) - serializer = self.get_serializer(queryset, many=True) + queryset = self.get_queryset().filter(user=self.request.user) + serializer = self.get_serializer(queryset, many=True) # similar DB hits return Response(serializer.data, status=status.HTTP_200_OK) return Response( {"errors": METHOD_ERROR_MESSAGE}, diff --git a/backend/api/products_serializers.py b/backend/api/products_serializers.py index dc97d6b8..e84789cf 100644 --- a/backend/api/products_serializers.py +++ b/backend/api/products_serializers.py @@ -195,7 +195,7 @@ class Meta: @classmethod def setup_eager_loading(cls, queryset, user): - """Perform necessary eager loading of product data.""" + """Perform necessary eager loading of products data.""" if user.is_anonymous: queryset = ( queryset.select_related("category", "subcategory", "producer") diff --git a/backend/api/products_views.py b/backend/api/products_views.py index 5ce61479..801b8534 100644 --- a/backend/api/products_views.py +++ b/backend/api/products_views.py @@ -522,13 +522,8 @@ def get_serializer_class(self): return ProductSerializer def get_queryset(self): - return ( - ProductSerializer.setup_eager_loading( - Product.objects.all(), self.request.user - ) - # Product.objects.select_related("category", "subcategory", "producer") - # .prefetch_related("components", "tags", "promotions", "reviews") - # .annotate(rating=Avg("reviews__score")) + return ProductSerializer.setup_eager_loading( + Product.objects.all(), self.request.user ) @transaction.atomic diff --git a/backend/api/recipes_serializers.py b/backend/api/recipes_serializers.py index 6b2c2861..3377aa81 100644 --- a/backend/api/recipes_serializers.py +++ b/backend/api/recipes_serializers.py @@ -88,6 +88,7 @@ def get_need_to_buy(self, obj) -> int: return ceil(obj.amount / obj.ingredient.amount) +# TODO: make setup_eager_loading cls method class RecipeSerializer(serializers.ModelSerializer): """Serializer for recipe representation.""" From ba5994f10d6516f5ff574d8b9db6a87c3515bb50 Mon Sep 17 00:00:00 2001 From: earlinn Date: Mon, 25 Dec 2023 20:18:26 +0600 Subject: [PATCH 8/9] Solve n+1 problem when loading recipe list --- backend/api/recipes_serializers.py | 75 +++++++++++++++++++++--------- backend/api/recipes_views.py | 2 +- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/backend/api/recipes_serializers.py b/backend/api/recipes_serializers.py index 3377aa81..e268a4c5 100644 --- a/backend/api/recipes_serializers.py +++ b/backend/api/recipes_serializers.py @@ -1,5 +1,6 @@ from math import ceil +from django.db.models import Count, F, Prefetch, Sum from drf_yasg import openapi from rest_framework import serializers @@ -88,13 +89,15 @@ def get_need_to_buy(self, obj) -> int: return ceil(obj.amount / obj.ingredient.amount) -# TODO: make setup_eager_loading cls method class RecipeSerializer(serializers.ModelSerializer): """Serializer for recipe representation.""" ingredients = ProductsInRecipeSerializer(source="recipeingredient", many=True) total_ingredients = serializers.SerializerMethodField() - recipe_nutrients = serializers.SerializerMethodField() + proteins = serializers.SerializerMethodField() + fats = serializers.SerializerMethodField() + carbohydrates = serializers.SerializerMethodField() + kcal = serializers.SerializerMethodField() class Meta: model = Recipe @@ -106,28 +109,54 @@ class Meta: "image", "ingredients", "total_ingredients", - "recipe_nutrients", + "proteins", + "fats", + "carbohydrates", + "kcal", "cooking_time", ) + @classmethod + def setup_eager_loading(cls, queryset): + """Perform necessary eager loading of recipes data.""" + return queryset.prefetch_related( + Prefetch( + "recipeingredient", + queryset=ProductsInRecipe.objects.select_related( + "ingredient" + ).prefetch_related("ingredient__promotions"), + ) + ).annotate( + total_ingredients=Count("ingredients"), + proteins=Sum( + F("recipeingredient__ingredient__proteins") + * F("recipeingredient__amount") + / 100 + ), + fats=Sum( + F("recipeingredient__ingredient__fats") + * F("recipeingredient__amount") + / 100 + ), + carbohydrates=Sum( + F("recipeingredient__ingredient__carbohydrates") + * F("recipeingredient__amount") + / 100 + ), + kcal=F("proteins") * 4 + F("fats") * 9 + F("carbohydrates") * 4, + ) + + def get_proteins(self, obj) -> float: + return round(obj.proteins, RECIPE_NUTRIENTS_DECIMAL_PLACES) + + def get_fats(self, obj) -> float: + return round(obj.fats, RECIPE_NUTRIENTS_DECIMAL_PLACES) + + def get_carbohydrates(self, obj) -> float: + return round(obj.carbohydrates, RECIPE_NUTRIENTS_DECIMAL_PLACES) + + def get_kcal(self, obj) -> int: + return round(obj.kcal, RECIPE_KCAL_DECIMAL_PLACES) + def get_total_ingredients(self, obj) -> int: - return obj.ingredients.count() - - def get_recipe_nutrients(self, obj) -> dict[str, float]: - proteins = 0 - fats = 0 - carbohydrates = 0 - - for ingredient in obj.ingredients.all(): - amount = ingredient.productsinrecipe.get(recipe=obj).amount - proteins += (ingredient.proteins * amount) / 100 - fats += (ingredient.fats * amount) / 100 - carbohydrates += (ingredient.carbohydrates * ingredient.amount) / 100 - kcal = proteins * 4 + fats * 9 + carbohydrates * 4 - - return { - "proteins": round(proteins, RECIPE_NUTRIENTS_DECIMAL_PLACES), - "fats": round(fats, RECIPE_NUTRIENTS_DECIMAL_PLACES), - "carbohydrates": round(carbohydrates, RECIPE_NUTRIENTS_DECIMAL_PLACES), - "kcal": round(kcal, RECIPE_KCAL_DECIMAL_PLACES), - } + return obj.total_ingredients diff --git a/backend/api/recipes_views.py b/backend/api/recipes_views.py index 97327939..3a54572c 100644 --- a/backend/api/recipes_views.py +++ b/backend/api/recipes_views.py @@ -5,5 +5,5 @@ class RecipeViewSet(ReadOnlyModelViewSet): - queryset = Recipe.objects.select_related("author").prefetch_related("ingredients") + queryset = RecipeSerializer.setup_eager_loading(Recipe.objects.all()) serializer_class = RecipeSerializer From 886bf97b9e16bc4149018c593a9c3c11dd9a0b9f Mon Sep 17 00:00:00 2001 From: earlinn Date: Mon, 25 Dec 2023 20:33:44 +0600 Subject: [PATCH 9/9] Add todo-comments on n+1 problem --- backend/api/products_serializers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/api/products_serializers.py b/backend/api/products_serializers.py index e84789cf..9637a609 100644 --- a/backend/api/products_serializers.py +++ b/backend/api/products_serializers.py @@ -396,6 +396,8 @@ class CategorySerializer(CategoryLightSerializer): class Meta(CategoryLightSerializer.Meta): fields = ("id", "name", "slug", "image", "subcategories", "top_three_products") + # TODO: need to solve n+1 problem + # TODO: possibly need to create an endpoint without this field @swagger_serializer_method(serializer_or_field=ProductSerializer(many=True)) def get_top_three_products(self, obj): """Shows three most popular products of a particular category.""" @@ -416,6 +418,7 @@ class TagSerializer(TagLightSerializer): class Meta(TagLightSerializer.Meta): fields = ("id", "name", "slug", "image", "top_three_products") + # TODO: need to solve n+1 problem @swagger_serializer_method(serializer_or_field=ProductSerializer(many=True)) def get_top_three_products(self, obj): """Shows three most popular products of a particular tag."""