From 06fdcacbc2a95e2fa277941afd2bed53d84b8d68 Mon Sep 17 00:00:00 2001 From: earlinn Date: Mon, 5 Feb 2024 13:32:08 +0600 Subject: [PATCH] Fix filtering products by category, subcategory, producer, components, tags and promotions; add new fields, ordering options and inlines to the Admin panel for categories, subcategories, components, tags, producers, promotions, coupons and products; update tests --- backend/api/filters.py | 18 +- backend/orders/shopping_carts.py | 4 +- backend/products/admin.py | 302 +++++++++++++----- backend/products/models.py | 7 +- .../tests/api_tests/test_product_filters.py | 49 ++- 5 files changed, 254 insertions(+), 126 deletions(-) diff --git a/backend/api/filters.py b/backend/api/filters.py index 6f8a5ea..b18e3a6 100644 --- a/backend/api/filters.py +++ b/backend/api/filters.py @@ -4,16 +4,22 @@ from products.models import Product +class CharFilterInFilter(rf_filters.BaseInFilter, rf_filters.CharFilter): + """Custom char filter allowing comma-separated incoming values.""" + + pass + + class ProductFilter(rf_filters.FilterSet): """Class for filtering products.""" name = rf_filters.CharFilter(method="startswith_contains_union_method") - category = rf_filters.AllValuesMultipleFilter(field_name="category__slug") - subcategory = rf_filters.AllValuesMultipleFilter(field_name="subcategory__slug") - producer = rf_filters.AllValuesMultipleFilter(field_name="producer__slug") - components = rf_filters.AllValuesMultipleFilter(field_name="components__slug") - tags = rf_filters.AllValuesMultipleFilter(field_name="tags__slug") - promotions = rf_filters.AllValuesMultipleFilter(field_name="promotions__slug") + category = CharFilterInFilter(field_name="category__slug") + subcategory = CharFilterInFilter(field_name="subcategory__slug") + producer = CharFilterInFilter(field_name="producer__slug") + components = CharFilterInFilter(field_name="components__slug") + tags = CharFilterInFilter(field_name="tags__slug") + promotions = CharFilterInFilter(field_name="promotions__slug") is_favorited = rf_filters.NumberFilter(method="product_boolean_methods") min_price = rf_filters.NumberFilter(method="get_min_price") max_price = rf_filters.NumberFilter(method="get_max_price") diff --git a/backend/orders/shopping_carts.py b/backend/orders/shopping_carts.py index ad4e4bf..40a3000 100644 --- a/backend/orders/shopping_carts.py +++ b/backend/orders/shopping_carts.py @@ -3,9 +3,7 @@ from django.utils import timezone from core.loggers import logger -from products.models import Coupon, Product - -PRICE_DECIMAL_PLACES = 2 +from products.models import PRICE_DECIMAL_PLACES, Coupon, Product class ShopCart(object): diff --git a/backend/products/admin.py b/backend/products/admin.py index 43f9599..c6f4084 100644 --- a/backend/products/admin.py +++ b/backend/products/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from django.db.models import Avg +from django.db.models import Avg, Count from .models import ( Category, @@ -13,13 +13,25 @@ Subcategory, Tag, ) +from api.products_serializers import RATING_DECIMAL_PLACES +from orders.models import Order +from reviews.models import Review class CategorySubcategoriesInline(admin.TabularInline): """Inline class to display subcategories of a category.""" model = Subcategory - extra = 1 + extra = 0 + + +class ComponentProductInline(admin.TabularInline): + """Built-in class for displaying products containing a given component.""" + + model = Product.components.through + readonly_fields = ["product"] + can_delete = False + extra = 0 class ProductPromotionsInline(admin.TabularInline): @@ -28,14 +40,61 @@ class ProductPromotionsInline(admin.TabularInline): model = ProductPromotion readonly_fields = ["promotion"] can_delete = False - extra = 1 + extra = 0 class ProductFavoritesInline(admin.TabularInline): """Inline class to display favorites of a product.""" model = FavoriteProduct - extra = 1 + readonly_fields = ["user"] + can_delete = False + extra = 0 + + +class ProductReviewInline(admin.TabularInline): + """Inline class to display reviews of a product.""" + + model = Review + readonly_fields = ["score", "text", "author", "was_edited"] + can_delete = False + extra = 0 + + +class PromotionProductInline(admin.TabularInline): + """ + Inline class to display the number of products to which this promotion is applied. + """ + + model = ProductPromotion + readonly_fields = ["product"] + extra = 0 + + +class CouponOrderInline(admin.TabularInline): + """Inline class to display the number of orders to which this coupon is applied.""" + + model = Order + fields = [ + "order_number", + "user", + "status", + "is_paid", + "payment_method", + "delivery_method", + "total_price", + ] + readonly_fields = [ + "order_number", + "user", + "status", + "is_paid", + "payment_method", + "delivery_method", + "total_price", + ] + can_delete = False + extra = 0 class UserFavoritesInline(admin.TabularInline): @@ -49,70 +108,134 @@ class UserFavoritesInline(admin.TabularInline): class CategoryAdmin(admin.ModelAdmin): """Class to display categories in admin panel.""" - list_display = ["pk", "name", "slug", "number_subcategories"] - list_display_links = ("name",) - fields = ["name", "slug", "image"] + list_display = ["pk", "name", "slug", "number_subcategories", "products_count"] + list_display_links = ["name"] search_fields = ["name", "slug"] - readonly_fields = ["number_subcategories"] + readonly_fields = ["number_subcategories", "products_count"] ordering = ["pk"] inlines = [CategorySubcategoriesInline] - @admin.display(description="Number of subcategories") + @admin.display(description="Subcategories count", ordering="number_subcategories") def number_subcategories(self, obj): """Shows the number of subcategories for this category.""" - return obj.subcategories.count() + return obj.number_subcategories + + @admin.display(description="Products count", ordering="products_count") + def products_count(self, obj): + """Shows the number of products for this category.""" + return obj.products_count def get_queryset(self, request): queryset = super().get_queryset(request) - return queryset.prefetch_related("subcategories") + return queryset.prefetch_related("subcategories", "products").annotate( + products_count=Count("products", distinct=True), + number_subcategories=Count("subcategories", distinct=True), + ) @admin.register(Subcategory) class SubcategoryAdmin(admin.ModelAdmin): """Class to display subcategories in admin panel.""" - list_display = ["pk", "name", "slug", "parent_category"] - list_display_links = ("name",) - fields = ["parent_category", "name", "slug", "image"] + list_display = ["pk", "name", "slug", "parent_category", "products_count"] + list_display_links = ["name"] + readonly_fields = ["products_count"] search_fields = ["name", "slug"] list_filter = ["parent_category"] ordering = ["pk"] + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.prefetch_related("products").annotate( + products_count=Count("products", distinct=True) + ) + + @admin.display(description="Products count", ordering="products_count") + def products_count(self, obj): + """Shows the number of products for this subcategory.""" + return obj.products_count + @admin.register(Component) class ComponentAdmin(admin.ModelAdmin): """Class to display product components in admin panel.""" - list_display = ["pk", "name", "slug"] - list_display_links = ("name",) - fields = ["name", "slug"] + list_display = ["pk", "name", "slug", "products_count"] + list_display_links = ["name"] + readonly_fields = ["products_count"] search_fields = ["name"] ordering = ["pk"] + inlines = [ComponentProductInline] + + @admin.display(description="Products count", ordering="products_count") + def products_count(self, obj): + """Shows the number of products for this component.""" + return obj.products_count + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .prefetch_related("products") + .annotate(products_count=Count("products", distinct=True)) + ) @admin.register(Tag) class TagAdmin(admin.ModelAdmin): """Class to display product tags in admin panel.""" - list_display = ["pk", "name", "slug"] - list_display_links = ("name",) - fields = ["name", "slug", "image"] + list_display = ["pk", "name", "slug", "products_count"] + list_display_links = ["name"] + readonly_fields = ["products_count"] search_fields = ["name", "slug"] ordering = ["pk"] + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.prefetch_related("products").annotate( + products_count=Count("products", distinct=True) + ) + + @admin.display(description="Products count", ordering="products_count") + def products_count(self, obj): + """Shows the number of products for this tag.""" + return obj.products_count + @admin.register(Producer) class ProducerAdmin(admin.ModelAdmin): """Class to display product producers in admin panel.""" - list_display = ["pk", "producer_type", "name", "slug", "address", "description"] - list_display_links = ("name",) - fields = ["producer_type", "name", "slug", "address", "description", "image"] + list_display = [ + "pk", + "producer_type", + "name", + "slug", + "address", + "description", + "products_count", + ] + list_display_links = ["name"] + readonly_fields = ["products_count"] search_fields = ["name", "slug", "address", "description"] ordering = ["pk"] list_filter = ["producer_type"] empty_value_display = "-empty-" + @admin.display(description="Products count", ordering="products_count") + def products_count(self, obj): + """Shows the number of products for this producer.""" + return obj.products_count + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .prefetch_related("products") + .annotate(products_count=Count("products", distinct=True)) + ) + @admin.register(Promotion) class PromotionAdmin(admin.ModelAdmin): @@ -128,24 +251,28 @@ class PromotionAdmin(admin.ModelAdmin): "is_constant", "start_time", "end_time", + "products_count", ] - list_display_links = ("name",) - fields = [ - "name", - "slug", - "promotion_type", - "discount", - "is_active", - "is_constant", - "start_time", - "end_time", - "conditions", - "image", - ] + list_display_links = ["name"] + readonly_fields = ["products_count"] search_fields = ["name", "discount", "conditions", "start_time", "end_time"] ordering = ["pk"] list_filter = ["promotion_type", "is_active", "is_constant"] empty_value_display = "-empty-" + inlines = [PromotionProductInline] + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .prefetch_related("products") + .annotate(products_count=Count("products", distinct=True)) + ) + + @admin.display(description="Products count", ordering="products_count") + def products_count(self, obj): + """Shows the number of products to which this coupon is applied.""" + return obj.products_count @admin.register(Coupon) @@ -163,29 +290,32 @@ class CouponAdmin(admin.ModelAdmin): "is_constant", "start_time", "end_time", + "orders_count", ] - list_display_links = ("code",) - fields = [ - "name", - "code", - "slug", - "promotion_type", - "discount", - "is_active", - "is_constant", - "start_time", - "end_time", - "conditions", - "image", - ] + readonly_fields = ["orders_count"] + list_display_links = ["code"] search_fields = ["name", "code", "discount", "conditions", "start_time", "end_time"] ordering = ["pk"] list_filter = ["promotion_type", "is_active", "is_constant"] empty_value_display = "-empty-" + inlines = [CouponOrderInline] def get_changeform_initial_data(self, request): return {"promotion_type": Promotion.COUPON} + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .prefetch_related("orders") + .annotate(orders_count=Count("orders", distinct=True)) + ) + + @admin.display(description="Orders count", ordering="orders_count") + def orders_count(self, obj): + """Shows the number of orders to which this coupon is applied.""" + return obj.orders_count + @admin.register(Product) class ProductAdmin(admin.ModelAdmin): @@ -196,40 +326,24 @@ class ProductAdmin(admin.ModelAdmin): "name", "category", "discontinued", - "producer", "price", "final_price", "rating", "views_number", "orders_number", + "favorites_count", + "reviews_count", ] - list_display_links = ("name",) - fields = [ - "name", - "description", + list_display_links = ["name"] + search_fields = ["name", "description", "producer__name"] + readonly_fields = [ "creation_time", - "category", - "subcategory", - "tags", - "discontinued", - "producer", - "price", + "promotion_quantity", "final_price", "rating", - "measure_unit", - "amount", - "promotion_quantity", - "components", - "kcal", - "proteins", - "fats", - "carbohydrates", - "views_number", - "orders_number", - "photo", + "favorites_count", + "reviews_count", ] - search_fields = ["name", "description", "producer__name"] - readonly_fields = ["creation_time", "promotion_quantity", "final_price", "rating"] ordering = ["pk"] list_filter = [ "category", @@ -239,28 +353,46 @@ class ProductAdmin(admin.ModelAdmin): "producer", "measure_unit", ] - inlines = [ProductPromotionsInline, ProductFavoritesInline] + inlines = [ProductPromotionsInline, ProductFavoritesInline, ProductReviewInline] empty_value_display = "-empty-" @admin.display(description="Number of promotions") def promotion_quantity(self, obj): """Shows the number of promotions for this product.""" - return obj.promotions.count() + return obj.promotion_quantity + + @admin.display(description="Favorites count", ordering="favorites_count") + def favorites_count(self, obj): + """Shows the number of favorites for this product.""" + return obj.favorites_count - @admin.display(description="Rating") + @admin.display(description="Reviews count", ordering="reviews_count") + def reviews_count(self, obj): + """Shows the number of reviews of this product.""" + return obj.reviews_count + + @admin.display(description="Rating", ordering="rating") def rating(self, obj): """Shows the product rating.""" - product_reviews = obj.reviews.all() - if product_reviews: - return round(product_reviews.aggregate(Avg("score"))["score__avg"], 1) - return "-empty-" + return ( + round(obj.rating, RATING_DECIMAL_PLACES) + if obj.rating is not None + else obj.rating + ) def get_queryset(self, request): queryset = super().get_queryset(request) return ( queryset.select_related("category", "subcategory", "producer") - .prefetch_related("components", "tags", "promotions", "reviews") - .annotate(rating=Avg("reviews__score")) + .prefetch_related( + "components", "tags", "promotions", "reviews", "favorites" + ) + .annotate( + promotion_quantity=Count("promotions", distinct=True), + favorites_count=Count("favorites", distinct=True), + reviews_count=Count("reviews", distinct=True), + rating=Avg("reviews__score"), + ) ) @@ -269,7 +401,7 @@ class ProductPromotionAdmin(admin.ModelAdmin): """Class to display connections between products and promotions.""" list_display = ["pk", "promotion", "product"] - list_display_links = ("promotion",) + list_display_links = ["promotion"] fields = ["promotion", "product"] @@ -278,7 +410,7 @@ class FavoriteProductAdmin(admin.ModelAdmin): """Class to display favorite products of users in admin panel.""" list_display = ["pk", "product", "user"] - list_display_links = ("product",) + list_display_links = ["product"] fields = ["user", "product"] search_fields = ["user__username", "product__name"] list_filter = ["product"] diff --git a/backend/products/models.py b/backend/products/models.py index a717a27..f8bb573 100644 --- a/backend/products/models.py +++ b/backend/products/models.py @@ -15,6 +15,7 @@ "он применяется к Корзине в целом." ) MAX_PROMOTIONS_NUMBER = 1 +PRICE_DECIMAL_PLACES = 2 class Category(CategoryModel): @@ -368,7 +369,11 @@ def final_price(self): if not self.promotions.all(): return self.price discount = self.promotions.all()[0].discount - return round(self.price * (1 - discount / 100), 2) if discount else self.price + return ( + round(self.price * (1 - discount / 100), PRICE_DECIMAL_PLACES) + if discount + else self.price + ) def is_favorited(self, user): """Checks whether the product is in the user's favorites.""" diff --git a/backend/tests/api_tests/test_product_filters.py b/backend/tests/api_tests/test_product_filters.py index 941757b..fa2cc4c 100644 --- a/backend/tests/api_tests/test_product_filters.py +++ b/backend/tests/api_tests/test_product_filters.py @@ -33,7 +33,7 @@ def test_product_category_filter(client, products, categories): @pytest.mark.django_db def test_product_category_filter_multiple(client, products, categories): - filter = f"?category={categories[0].slug}&category={categories[1].slug}" + filter = f"?category={categories[0].slug},{categories[1].slug}" response = client.get(reverse("api:product-list") + filter) assert response.status_code == 200 @@ -55,11 +55,8 @@ def test_product_category_filter_fail_invalid_slug(client, categories): filter = f"?category={categories[1].slug[:2]}" response = client.get(reverse("api:product-list") + filter) - assert response.status_code == 400 - assert response.data["type"] == "validation_error" - assert response.data["errors"][0]["code"] == "invalid_choice" - assert response.data["errors"][0]["attr"] == "category" - + assert response.status_code == 200 + assert response.data["count"] == 0 @pytest.mark.django_db def test_product_subcategory_filter(client, products, subcategories): @@ -78,7 +75,7 @@ def test_product_subcategory_filter(client, products, subcategories): @pytest.mark.django_db def test_product_subcategory_filter_multiple(client, products, subcategories): - filter = f"?subcategory={subcategories[1].slug}&subcategory={subcategories[2].slug}" + filter = f"?subcategory={subcategories[1].slug},{subcategories[2].slug}" response = client.get(reverse("api:product-list") + filter) assert response.status_code == 200 @@ -102,10 +99,8 @@ def test_product_subcategory_filter_fail_invalid_slug(client, subcategories): filter = f"?subcategory={subcategories[1].slug[:2]}" response = client.get(reverse("api:product-list") + filter) - assert response.status_code == 400 - assert response.data["type"] == "validation_error" - assert response.data["errors"][0]["code"] == "invalid_choice" - assert response.data["errors"][0]["attr"] == "subcategory" + assert response.status_code == 200 + assert response.data["count"] == 0 @pytest.mark.django_db @@ -122,7 +117,7 @@ def test_product_producer_filter(client, products, producers): @pytest.mark.django_db def test_product_producer_filter_multiple(client, products, producers): - filter = f"?producer={producers[0].slug}&producer={producers[1].slug}" + filter = f"?producer={producers[0].slug},{producers[1].slug}" response = client.get(reverse("api:product-list") + filter) assert response.status_code == 200 @@ -143,10 +138,8 @@ def test_product_producer_filter_fail_invalid_slug(client, producers): filter = f"?producer={producers[1].slug[:2]}" response = client.get(reverse("api:product-list") + filter) - assert response.status_code == 400 - assert response.data["type"] == "validation_error" - assert response.data["errors"][0]["code"] == "invalid_choice" - assert response.data["errors"][0]["attr"] == "producer" + assert response.status_code == 200 + assert response.data["count"] == 0 @pytest.mark.django_db @@ -166,7 +159,7 @@ def test_product_components_filter(client, products, components): @pytest.mark.django_db def test_product_components_filter_multiple(client, products, components): - filter = f"?components={components[3].slug}&components={components[2].slug}" + filter = f"?components={components[3].slug},{components[2].slug}" response = client.get(reverse("api:product-list") + filter) assert response.status_code == 200 @@ -194,10 +187,8 @@ def test_product_components_filter_fail_invalid_slug(client, components): filter = f"?components={components[1].slug[:2]}" response = client.get(reverse("api:product-list") + filter) - assert response.status_code == 400 - assert response.data["type"] == "validation_error" - assert response.data["errors"][0]["code"] == "invalid_choice" - assert response.data["errors"][0]["attr"] == "components" + assert response.status_code == 200 + assert response.data["count"] == 0 @pytest.mark.django_db @@ -217,7 +208,7 @@ def test_product_tags_filter(client, products, tags): def test_product_tags_filter_multiple(client, products, tags): products[1].tags.set([tags[1]]) products[2].tags.set([tags[0]]) - filter = f"?tags={tags[0].slug}&tags={tags[1].slug}" + filter = f"?tags={tags[0].slug},{tags[1].slug}" response = client.get(reverse("api:product-list") + filter) assert response.status_code == 200 @@ -235,10 +226,8 @@ def test_product_tags_filter_fail_invalid_slug(client, tags): filter = f"?tags={tags[1].slug[:2]}" response = client.get(reverse("api:product-list") + filter) - assert response.status_code == 400 - assert response.data["type"] == "validation_error" - assert response.data["errors"][0]["code"] == "invalid_choice" - assert response.data["errors"][0]["attr"] == "tags" + assert response.status_code == 200 + assert response.data["count"] == 0 @pytest.mark.django_db @@ -261,7 +250,7 @@ def test_product_promotions_filter(client, products, promotions): 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].slug}&promotions={promotions[1].slug}" + filter = f"?promotions={promotions[0].slug},{promotions[1].slug}" response = client.get(reverse("api:product-list") + filter) assert response.status_code == 200 @@ -285,10 +274,8 @@ def test_product_promotions_filter_fail_invalid_slug(client): filter = f"?promotions={TEST_NUMBER}" response = client.get(reverse("api:product-list") + filter) - assert response.status_code == 400 - assert response.data["type"] == "validation_error" - assert response.data["errors"][0]["code"] == "invalid_choice" - assert response.data["errors"][0]["attr"] == "promotions" + assert response.status_code == 200 + assert response.data["count"] == 0 @pytest.mark.django_db