From 3250390879f1c901ce47ea2cab67c763b5aa2d77 Mon Sep 17 00:00:00 2001 From: earlinn Date: Fri, 2 Feb 2024 16:27:54 +0600 Subject: [PATCH 1/9] Set automatically correct promotion_type during coupon creation --- backend/api/products_serializers.py | 11 ++--------- backend/api/products_views.py | 12 ++++++++++++ backend/api/reviews_views.py | 2 ++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/backend/api/products_serializers.py b/backend/api/products_serializers.py index 73c64fd..d0776c2 100644 --- a/backend/api/products_serializers.py +++ b/backend/api/products_serializers.py @@ -18,9 +18,6 @@ Tag, ) -COUPON_PROMOTION_TYPE_API_ERROR_MESSAGE = ( - f"Указан неверный тип промоакции, нужно выбрать {Promotion.COUPON}." -) RATING_DECIMAL_PLACES = 1 @@ -516,6 +513,8 @@ def setup_eager_loading(cls, queryset, user): class CouponSerializer(serializers.ModelSerializer): """Serializer for coupons representation.""" + promotion_type = serializers.ReadOnlyField() + class Meta: model = Coupon fields = ( @@ -533,12 +532,6 @@ class Meta: "image", ) - def validate_promotion_type(self, value): - """Checks that promotion_type is correct.""" - if value != Promotion.COUPON: - raise serializers.ValidationError(COUPON_PROMOTION_TYPE_API_ERROR_MESSAGE) - return value - class CouponApplySerializer(serializers.ModelSerializer): """Serializer to apply coupon promoaction to the order.""" diff --git a/backend/api/products_views.py b/backend/api/products_views.py index 4ee1d0f..14318c9 100644 --- a/backend/api/products_views.py +++ b/backend/api/products_views.py @@ -577,6 +577,12 @@ class CouponViewSet(DestroyWithPayloadMixin, viewsets.ModelViewSet): serializer_class = CouponSerializer permission_classes = [IsAdminOrReadOnly] + @transaction.atomic + def perform_create(self, serializer): + """Sets the correct promotion_type during coupon creation.""" + serializer.save(promotion_type=Promotion.COUPON) + return super().perform_create(serializer) + @method_decorator( name="list", @@ -664,6 +670,9 @@ def get_queryset(self): @transaction.atomic def perform_create(self, serializer): + """ + Sets the correct category for the given subcategory during product creation. + """ subcategory_id = serializer._kwargs["data"]["subcategory"] subcategory = Subcategory.objects.get(id=subcategory_id) serializer.save(category=subcategory.parent_category) @@ -671,6 +680,9 @@ def perform_create(self, serializer): @transaction.atomic def perform_update(self, serializer): + """ + Sets the correct category for the given subcategory during product editing. + """ subcategory_id = serializer._kwargs["data"].get("subcategory") if subcategory_id: subcategory = Subcategory.objects.get(id=subcategory_id) diff --git a/backend/api/reviews_views.py b/backend/api/reviews_views.py index 267a96d..3991ddd 100644 --- a/backend/api/reviews_views.py +++ b/backend/api/reviews_views.py @@ -101,10 +101,12 @@ def get_queryset(self): ).select_related("product", "author") def perform_create(self, serializer): + """Sets the correct author and product during review creation.""" product = get_object_or_404(Product, pk=self.kwargs.get("product_id")) serializer.save(author=self.request.user, product=product) def perform_update(self, serializer): + """Updates pub_date and was_edited fields during review editing.""" serializer.save(pub_date=timezone.now(), was_edited=True) return super().perform_update(serializer) From 6e61fe4aa68d040639b74a1662172c1703fb6974 Mon Sep 17 00:00:00 2001 From: earlinn Date: Fri, 2 Feb 2024 16:41:48 +0600 Subject: [PATCH 2/9] Make coupons (promocodes) case sensitive --- backend/api/orders_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/orders_views.py b/backend/api/orders_views.py index 6e12ce3..7fbaba7 100644 --- a/backend/api/orders_views.py +++ b/backend/api/orders_views.py @@ -198,7 +198,7 @@ def coupon_apply(self, request): code = request.data["code"] try: coupon = Coupon.objects.get( - Q(code__iexact=code), + Q(code__exact=code), Q(is_active=True), Q(start_time__lte=now) | Q(start_time__isnull=True), Q(end_time__gte=now) | Q(end_time__isnull=True), From 7cd9214e8d2b72e57ff505abbd66e6c6ad7fa4bc Mon Sep 17 00:00:00 2001 From: earlinn Date: Fri, 2 Feb 2024 16:50:50 +0600 Subject: [PATCH 3/9] =?UTF-8?q?Prohibit=20coupon=20(promo=D1=81ode)=20appl?= =?UTF-8?q?ication=20to=20existing=20orders=20on=20the=20Admin=20Panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/orders/admin.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/backend/orders/admin.py b/backend/orders/admin.py index e142743..7908bea 100644 --- a/backend/orders/admin.py +++ b/backend/orders/admin.py @@ -5,20 +5,20 @@ @admin.register(Delivery) class DeliveryAdmin(admin.ModelAdmin): - list_display = ("id", "delivery_point") - list_display_links = ("delivery_point",) + list_display = ["id", "delivery_point"] + list_display_links = ["delivery_point"] class OrderProductInline(admin.TabularInline): model = OrderProduct - list_display = ("id", "product", "quantity") - list_editable = ("product", "quantity") + list_display = ["id", "product", "quantity"] + list_editable = ["product", "quantity"] @admin.register(Order) class OrderAdmin(admin.ModelAdmin): - inlines = (OrderProductInline,) - list_display = ( + inlines = [OrderProductInline] + list_display = [ "id", "order_number", "user", @@ -30,10 +30,11 @@ class OrderAdmin(admin.ModelAdmin): "total_price", "coupon_applied", "coupon_discount", - ) - list_display_links = ("ordering_date",) - list_editable = ("payment_method", "delivery_method") - list_filter = ("status", "ordering_date") + ] + list_display_links = ["ordering_date"] + list_editable = ["payment_method", "delivery_method"] + list_filter = ["status", "ordering_date"] + readonly_fields = ["coupon_applied", "coupon_discount"] def get_queryset(self, request): queryset = super().get_queryset(request) From 1c7ed517fa442fb005165f1a88086643aa97d93c Mon Sep 17 00:00:00 2001 From: earlinn Date: Sat, 3 Feb 2024 12:50:39 +0600 Subject: [PATCH 4/9] Check the user's age (from 6 to 110 years old is acceptable) at the API level, update this check at the model level --- backend/api/users_serializers.py | 18 +++++++++++++++++- backend/users/models.py | 27 +++++++++++++++++++++------ poetry.lock | 27 ++++++++++++++++++++++++++- pyproject.toml | 1 + 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/backend/api/users_serializers.py b/backend/api/users_serializers.py index 83b3a03..d84723c 100644 --- a/backend/api/users_serializers.py +++ b/backend/api/users_serializers.py @@ -1,12 +1,20 @@ +from dateutil.relativedelta import relativedelta from django.contrib.auth import get_user_model from django.db import transaction +from django.utils import timezone from djoser.serializers import UserCreateSerializer as DjoserUserCreateSerializer from djoser.serializers import UserDeleteSerializer as DjoserUserDeleteSerializer from djoser.serializers import UserSerializer as DjoserUserSerializer from rest_framework import serializers from rest_framework.validators import UniqueValidator -from users.models import Address +from users.models import ( + BIRTH_DATE_TOO_OLD_ERROR_MESSAGE, + BIRTH_DATE_TOO_YOUNG_ERROR_MESSAGE, + MAX_USER_AGE, + MIN_USER_AGE, + Address, +) from users.utils import city_choices User = get_user_model() @@ -69,6 +77,14 @@ class Meta: "photo", ) + def validate_birth_date(self, value): + now = timezone.now() + if value + relativedelta(years=MIN_USER_AGE) > now.date(): + raise serializers.ValidationError(BIRTH_DATE_TOO_YOUNG_ERROR_MESSAGE) + if value + relativedelta(years=MAX_USER_AGE) < now.date(): + raise serializers.ValidationError(BIRTH_DATE_TOO_OLD_ERROR_MESSAGE) + return value + def get_address_quantity(self, obj) -> int: return obj.addresses.count() diff --git a/backend/users/models.py b/backend/users/models.py index ce6b23d..d9cec3d 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,3 +1,4 @@ +from dateutil.relativedelta import relativedelta from django.contrib.auth.models import AbstractUser from django.contrib.auth.validators import UnicodeUsernameValidator from django.core.exceptions import ValidationError @@ -13,6 +14,16 @@ "форматах '+7XXXXXXXXXX', '7XXXXXXXXXX' или '8XXXXXXXXXX'." ) PHONE_NUMBER_REGEX = r"^(\+7|7|8)\d{10}$" +MIN_USER_AGE = 6 +MAX_USER_AGE = 110 +BIRTH_DATE_TOO_YOUNG_ERROR_MESSAGE = ( + "Указана неверная дата рождения, " + f"пользователю должно быть не менее {MIN_USER_AGE} лет." +) +BIRTH_DATE_TOO_OLD_ERROR_MESSAGE = ( + "Указана неверная дата рождения, " + f"пользователю должно быть не более {MAX_USER_AGE} лет." +) @cleanup.select @@ -59,12 +70,16 @@ def clean_fields(self, exclude=None): """Checks the user's birth date.""" super().clean_fields(exclude=exclude) now = timezone.now() - if self.birth_date: - if ( - self.birth_date.year > now.year - or (now.year - self.birth_date.year) > 120 - ): - raise ValidationError("Указана неверная дата рождения.") + if ( + self.birth_date + and self.birth_date + relativedelta(years=MIN_USER_AGE) > now.date() + ): + raise ValidationError(BIRTH_DATE_TOO_YOUNG_ERROR_MESSAGE) + if ( + self.birth_date + and self.birth_date + relativedelta(years=MAX_USER_AGE) < now.date() + ): + raise ValidationError(BIRTH_DATE_TOO_OLD_ERROR_MESSAGE) class Address(models.Model): diff --git a/poetry.lock b/poetry.lock index 7d48642..c24c18c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1137,6 +1137,20 @@ pytest = ">=7.0.0" docs = ["sphinx", "sphinx-rtd-theme"] testing = ["Django", "django-configurations (>=2.0)"] +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" version = "1.0.0" @@ -1401,6 +1415,17 @@ files = [ {file = "rpds_py-0.12.0.tar.gz", hash = "sha256:7036316cc26b93e401cedd781a579be606dad174829e6ad9e9c5a0da6e036f80"}, ] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "social-auth-app-django" version = "5.4.0" @@ -1536,4 +1561,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "def4f9b11c18cf5bd2ae56ac30d2dc25ad1833bc78ef40fa4fd3a4eb2a190b48" +content-hash = "343d60ded67368126a186ea8603f2e85435faf73c2656cd32cd58bdccb514700" diff --git a/pyproject.toml b/pyproject.toml index 4ea716e..fc6e4d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ pytest = "^7.4.3" pytest-django = "^4.7.0" django-phonenumber-field = {extras = ["phonenumberslite"], version = "^7.2.0"} stripe = "^7.8.2" +python-dateutil = "^2.8.2" [tool.poetry.group.dev.dependencies] From c84d06064537c319ac76faba8b107660522c99ce Mon Sep 17 00:00:00 2001 From: earlinn Date: Sat, 3 Feb 2024 20:00:09 +0600 Subject: [PATCH 5/9] Fix test for birth_date deletion, prevent pytest from running unittests from django.test during GitHub Actions workflow --- .github/workflows/good_food_workflow.yaml | 4 +++- backend/api/users_serializers.py | 4 ++-- backend/tests/api_tests/test_user.py | 17 ++++++++++------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/good_food_workflow.yaml b/.github/workflows/good_food_workflow.yaml index 3f6520f..2643a99 100644 --- a/.github/workflows/good_food_workflow.yaml +++ b/.github/workflows/good_food_workflow.yaml @@ -39,8 +39,10 @@ jobs: python -m flake8 - name: Test with pytest run: | + pwd + ls cd backend - pytest + pytest backend/tests/ - name: send message if: ${{ github.ref != 'refs/heads/main' }} uses: appleboy/telegram-action@master diff --git a/backend/api/users_serializers.py b/backend/api/users_serializers.py index d84723c..2fdf1bf 100644 --- a/backend/api/users_serializers.py +++ b/backend/api/users_serializers.py @@ -79,9 +79,9 @@ class Meta: def validate_birth_date(self, value): now = timezone.now() - if value + relativedelta(years=MIN_USER_AGE) > now.date(): + if value and value + relativedelta(years=MIN_USER_AGE) > now.date(): raise serializers.ValidationError(BIRTH_DATE_TOO_YOUNG_ERROR_MESSAGE) - if value + relativedelta(years=MAX_USER_AGE) < now.date(): + if value and value + relativedelta(years=MAX_USER_AGE) < now.date(): raise serializers.ValidationError(BIRTH_DATE_TOO_OLD_ERROR_MESSAGE) return value diff --git a/backend/tests/api_tests/test_user.py b/backend/tests/api_tests/test_user.py index e499ca6..f8f9973 100644 --- a/backend/tests/api_tests/test_user.py +++ b/backend/tests/api_tests/test_user.py @@ -1,5 +1,3 @@ -import json - import pytest from django.urls import reverse @@ -131,7 +129,6 @@ def test_patch_me_birth_date_post_fail(user, auth_client): ) -@pytest.mark.skip(reason="Not passing now, need to fix") @pytest.mark.django_db def test_patch_me_birth_date_set_null(user, auth_client): payload = {"birth_date": BIRTH_DATE} @@ -140,12 +137,18 @@ def test_patch_me_birth_date_set_null(user, auth_client): assert response_post.status_code == 200 assert response_post.data["birth_date"] == BIRTH_DATE - payload2 = {"birth_date": None} - + # pytest/unittest don't accept None (i.e. null) in payload and responds with + # the error "TypeError: Cannot encode None for key 'birth_date' as POST data. + # Did you mean to pass an empty string or omit the value?" + # However, if we pass an empty string as payload, pytest will count it as null. + # We also wanted to make a test for an empty string, to which our API responds + # with code 400 (incorrect date format error), but pytest/unittest equates it + # to None, so we cannot write such a test. + payload_to_delete_birth_date = {"birth_date": ""} response_delete = auth_client.patch( - reverse("api:user-me"), json.dumps(payload2), headers="application/json" + reverse("api:user-me"), payload_to_delete_birth_date ) - + assert response_delete.status_code == 200 assert response_delete.data["birth_date"] is None From 703683183ffc72e5f0856f1fa1794dedaacf0b45 Mon Sep 17 00:00:00 2001 From: earlinn Date: Sat, 3 Feb 2024 20:08:07 +0600 Subject: [PATCH 6/9] Try to spesify correct path to tests for pytest during GitHub Actions workflow execution --- .github/workflows/good_food_workflow.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/good_food_workflow.yaml b/.github/workflows/good_food_workflow.yaml index 2643a99..09141ac 100644 --- a/.github/workflows/good_food_workflow.yaml +++ b/.github/workflows/good_food_workflow.yaml @@ -39,9 +39,6 @@ jobs: python -m flake8 - name: Test with pytest run: | - pwd - ls - cd backend pytest backend/tests/ - name: send message if: ${{ github.ref != 'refs/heads/main' }} From 3c4bfa990f461f4613787455b60f8102ef96e519 Mon Sep 17 00:00:00 2001 From: earlinn Date: Sat, 3 Feb 2024 21:13:07 +0600 Subject: [PATCH 7/9] Make tests for too young and too old users, get rid of hardcoded birth date format error message --- backend/tests/api_tests/test_user.py | 63 +++++++++++++++++++++++----- backend/users/tests/test_user.py | 4 +- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/backend/tests/api_tests/test_user.py b/backend/tests/api_tests/test_user.py index f8f9973..1cd4c1c 100644 --- a/backend/tests/api_tests/test_user.py +++ b/backend/tests/api_tests/test_user.py @@ -1,5 +1,8 @@ import pytest +from dateutil.relativedelta import relativedelta from django.urls import reverse +from django.utils import timezone +from rest_framework.fields import DateField from api.mixins import MESSAGE_ON_DELETE from tests.fixtures import ( @@ -12,7 +15,17 @@ USER, USER_EMAIL, ) -from users.models import PHONE_NUMBER_ERROR +from users.models import ( + BIRTH_DATE_TOO_OLD_ERROR_MESSAGE, + BIRTH_DATE_TOO_YOUNG_ERROR_MESSAGE, + MAX_USER_AGE, + MIN_USER_AGE, + PHONE_NUMBER_ERROR, +) + +BIRTH_DATE_FORMAT_ERROR_MESSAGE = DateField.default_error_messages["invalid"].format( + format="DD.MM.YYYY" +) @pytest.mark.django_db @@ -106,16 +119,52 @@ def test_patch_me_first_last_names(user, auth_client): @pytest.mark.django_db def test_patch_me_birth_date_post(user, auth_client): response_get = auth_client.get(reverse("api:user-me")) - assert response_get.data["birth_date"] is None payload = {"birth_date": BIRTH_DATE} response_post = auth_client.patch(reverse("api:user-me"), payload) - assert response_post.status_code == 200 assert response_post.data["birth_date"] == BIRTH_DATE +@pytest.mark.django_db +def test_patch_me_too_young_birth_date(user, auth_client): + response_get = auth_client.get(reverse("api:user-me")) + assert response_get.data["birth_date"] is None + + date = ( + timezone.now().date() + - relativedelta(years=MIN_USER_AGE) + + relativedelta(days=1) + ) + date = date.__format__("%d.%m.%Y") + payload = {"birth_date": date} + response = auth_client.patch(reverse("api:user-me"), payload) + assert response.status_code == 400 + assert response.data["type"] == "validation_error" + assert response.data["errors"][0]["code"] == "invalid" + assert response.data["errors"][0]["detail"] == BIRTH_DATE_TOO_YOUNG_ERROR_MESSAGE + + +@pytest.mark.django_db +def test_patch_me_too_old_birth_date(user, auth_client): + response_get = auth_client.get(reverse("api:user-me")) + assert response_get.data["birth_date"] is None + + date = ( + timezone.now().date() + - relativedelta(years=MAX_USER_AGE) + - relativedelta(days=1) + ) + date = date.__format__("%d.%m.%Y") + payload = {"birth_date": date} + response = auth_client.patch(reverse("api:user-me"), payload) + assert response.status_code == 400 + assert response.data["type"] == "validation_error" + assert response.data["errors"][0]["code"] == "invalid" + assert response.data["errors"][0]["detail"] == BIRTH_DATE_TOO_OLD_ERROR_MESSAGE + + @pytest.mark.django_db def test_patch_me_birth_date_post_fail(user, auth_client): payload = {"birth_date": "01-01-2000"} @@ -124,16 +173,13 @@ def test_patch_me_birth_date_post_fail(user, auth_client): assert response.status_code == 400 assert response.data["type"] == "validation_error" assert response.data["errors"][0]["code"] == "invalid" - assert response.data["errors"][0]["detail"] == ( - "Неправильный формат date. Используйте один из этих форматов: DD.MM.YYYY." - ) + assert response.data["errors"][0]["detail"] == BIRTH_DATE_FORMAT_ERROR_MESSAGE @pytest.mark.django_db def test_patch_me_birth_date_set_null(user, auth_client): payload = {"birth_date": BIRTH_DATE} response_post = auth_client.patch(reverse("api:user-me"), payload) - assert response_post.status_code == 200 assert response_post.data["birth_date"] == BIRTH_DATE @@ -155,12 +201,10 @@ def test_patch_me_birth_date_set_null(user, auth_client): @pytest.mark.django_db def test_patch_me_phone_number(user, auth_client): response_get = auth_client.get(reverse("api:user-me")) - assert response_get.data["phone_number"] == "" payload = {"phone_number": PHONE_NUMBER} response_post = auth_client.patch(reverse("api:user-me"), payload) - assert response_post.status_code == 200 assert response_post.data["phone_number"] == user.phone_number == PHONE_NUMBER @@ -172,7 +216,6 @@ def test_patch_me_phone_number(user, auth_client): payload = {"phone_number": "4"} response = auth_client.patch(reverse("api:user-me"), payload) assert response.status_code == 400 - assert response.data["type"] == "validation_error" assert response.data["errors"][0]["code"] == "invalid" assert response.data["errors"][0]["detail"] == PHONE_NUMBER_ERROR diff --git a/backend/users/tests/test_user.py b/backend/users/tests/test_user.py index 7234531..bace9ba 100644 --- a/backend/users/tests/test_user.py +++ b/backend/users/tests/test_user.py @@ -3,6 +3,7 @@ from rest_framework import status from rest_framework.test import APIClient +from tests.api_tests.test_user import BIRTH_DATE_FORMAT_ERROR_MESSAGE from users.models import PHONE_NUMBER_ERROR, Address User = get_user_model() @@ -85,8 +86,7 @@ def test_user_add_birthdate(self): self.assertEqual(response.data["type"], "validation_error") self.assertEqual(response.data["errors"][0]["code"], "invalid") self.assertEqual( - response.data["errors"][0]["detail"], - "Неправильный формат date. Используйте один из этих форматов: DD.MM.YYYY.", + response.data["errors"][0]["detail"], BIRTH_DATE_FORMAT_ERROR_MESSAGE ) def test_user_add_phone_number(self): From 06fdcacbc2a95e2fa277941afd2bed53d84b8d68 Mon Sep 17 00:00:00 2001 From: earlinn Date: Mon, 5 Feb 2024 13:32:08 +0600 Subject: [PATCH 8/9] 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 From 44ccab7e43575a7becc3d6351ab181338cc63ab9 Mon Sep 17 00:00:00 2001 From: earlinn Date: Mon, 5 Feb 2024 13:33:53 +0600 Subject: [PATCH 9/9] Fix flake8 error --- backend/tests/api_tests/test_product_filters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tests/api_tests/test_product_filters.py b/backend/tests/api_tests/test_product_filters.py index fa2cc4c..befaed0 100644 --- a/backend/tests/api_tests/test_product_filters.py +++ b/backend/tests/api_tests/test_product_filters.py @@ -58,6 +58,7 @@ def test_product_category_filter_fail_invalid_slug(client, categories): assert response.status_code == 200 assert response.data["count"] == 0 + @pytest.mark.django_db def test_product_subcategory_filter(client, products, subcategories): filter = f"?subcategory={subcategories[1].slug}"