Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Develop #249

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0ac0bd6
Make full paths to photos of products in shopping cart
earlinn Jan 27, 2024
bcb6c85
Merge branch 'main' into fix/shoppingcart_product_photo_full_paths
earlinn Jan 31, 2024
a2e3f75
Update README section on local launch of a project in docker containers
earlinn Jan 31, 2024
8dccf3a
Make unique order numbers, add a number uniqueness check to the gener…
earlinn Feb 1, 2024
4faf17a
Prohibit the use of coupons for individual products, allowing them fo…
earlinn Feb 1, 2024
29335d6
Create api endpoint to get, post, patch and delete coupons
earlinn Feb 1, 2024
a11407c
Add pub_date field to recipe endpoints, update Recipe model docstring…
earlinn Feb 1, 2024
b23b07a
Add coupons to the load/export data commands and update these command…
earlinn Feb 1, 2024
a37ca24
Update the README section regarding users
earlinn Feb 1, 2024
eb25238
Fix README
earlinn Feb 1, 2024
04a2ac2
Add some query examples to README
earlinn Feb 1, 2024
31daad0
Merge pull request #247 from healthy-food-and-dietary-products/fix/sh…
juliana-str Feb 1, 2024
3250390
Set automatically correct promotion_type during coupon creation
earlinn Feb 2, 2024
6e61fe4
Make coupons (promocodes) case sensitive
earlinn Feb 2, 2024
7cd9214
Prohibit coupon (promoсode) application to existing orders on the Adm…
earlinn Feb 2, 2024
1c7ed51
Check the user's age (from 6 to 110 years old is acceptable) at the A…
earlinn Feb 3, 2024
c84d060
Fix test for birth_date deletion, prevent pytest from running unittes…
earlinn Feb 3, 2024
7036831
Try to spesify correct path to tests for pytest during GitHub Actions…
earlinn Feb 3, 2024
3c4bfa9
Make tests for too young and too old users, get rid of hardcoded birt…
earlinn Feb 3, 2024
06fdcac
Fix filtering products by category, subcategory, producer, components…
earlinn Feb 5, 2024
44ccab7
Fix flake8 error
earlinn Feb 5, 2024
edc62a5
Merge pull request #248 from healthy-food-and-dietary-products/fix/sh…
juliana-str Feb 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/good_food_workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ jobs:
python -m flake8
- name: Test with pytest
run: |
cd backend
pytest
pytest backend/tests/
- name: send message
if: ${{ github.ref != 'refs/heads/main' }}
uses: appleboy/telegram-action@master
Expand Down
453 changes: 425 additions & 28 deletions README.md

Large diffs are not rendered by default.

18 changes: 12 additions & 6 deletions backend/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 4 additions & 4 deletions backend/api/orders_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
StripePaySuccessPageSerializer,
StripeSessionCreateSerializer,
)
from .products_serializers import CouponSerializer
from .products_serializers import CouponApplySerializer
from .products_views import STATUS_200_RESPONSE_ON_DELETE_IN_DOCS
from core.loggers import logger
from core.utils import generate_order_number
Expand Down Expand Up @@ -114,7 +114,7 @@
decorator=swagger_auto_schema(
operation_summary="Apply promocode",
responses={
201: CouponSerializer,
201: CouponApplySerializer,
400: ErrorResponse406Serializer,
403: ErrorResponse403Serializer,
},
Expand All @@ -137,7 +137,7 @@ def get_serializer_class(self):
if self.request.method in permissions.SAFE_METHODS:
return ShoppingCartListSerializer
if self.action == "coupon_apply":
return CouponSerializer
return CouponApplySerializer
return ShoppingCartSerializer

# TODO: test this endpoint
Expand Down Expand Up @@ -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),
Expand Down
31 changes: 29 additions & 2 deletions backend/api/products_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .users_serializers import UserLightSerializer
from orders.shopping_carts import ShopCart
from products.models import (
INCORRECT_COUPON_APPLY_ERROR,
MAX_PROMOTIONS_NUMBER,
Category,
Component,
Expand Down Expand Up @@ -123,7 +124,7 @@ class Meta:
fields = ("promotion_name", "promotion_slug", "discount")


class PromotionSerializer(ProducerLightSerializer):
class PromotionSerializer(PromotionLightSerializer):
"""Serializer for promotions representation."""

class Meta(PromotionLightSerializer.Meta):
Expand Down Expand Up @@ -287,7 +288,7 @@ class ProductUpdateSerializer(ProductCreateSerializer):
many=True, queryset=Promotion.objects.all()
)

class Meta(ProductSerializer.Meta):
class Meta(ProductCreateSerializer.Meta):
fields = (
"id",
"name",
Expand Down Expand Up @@ -317,6 +318,9 @@ def validate_promotions(self, value):
raise serializers.ValidationError(
ProductPromotion.MAX_PROMOTIONS_ERROR_MESSAGE
)
for promotion in value:
if promotion.promotion_type == Promotion.COUPON:
raise serializers.ValidationError(INCORRECT_COUPON_APPLY_ERROR)
return value


Expand Down Expand Up @@ -507,6 +511,29 @@ def setup_eager_loading(cls, queryset, user):


class CouponSerializer(serializers.ModelSerializer):
"""Serializer for coupons representation."""

promotion_type = serializers.ReadOnlyField()

class Meta:
model = Coupon
fields = (
"id",
"code",
"name",
"slug",
"promotion_type",
"discount",
"is_active",
"is_constant",
"start_time",
"end_time",
"conditions",
"image",
)


class CouponApplySerializer(serializers.ModelSerializer):
"""Serializer to apply coupon promoaction to the order."""

name = serializers.ReadOnlyField()
Expand Down
79 changes: 79 additions & 0 deletions backend/api/products_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
CategoryCreateSerializer,
CategorySerializer,
ComponentSerializer,
CouponSerializer,
FavoriteProductCreateSerializer,
FavoriteProductDeleteSerializer,
FavoriteProductSerializer,
Expand All @@ -40,6 +41,7 @@
from products.models import (
Category,
Component,
Coupon,
FavoriteProduct,
Producer,
Product,
Expand Down Expand Up @@ -511,6 +513,77 @@ class PromotionViewSet(DestroyWithPayloadMixin, viewsets.ModelViewSet):
permission_classes = [IsAdminOrReadOnly]


@method_decorator(
name="list",
decorator=swagger_auto_schema(
operation_summary="List all coupons",
operation_description="Returns a list of all the coupons",
responses={200: CouponSerializer},
),
)
@method_decorator(
name="retrieve",
decorator=swagger_auto_schema(
operation_summary="Get coupon by id",
operation_description="Retrieves a coupon by its id",
responses={200: CouponSerializer, 404: ErrorResponse404Serializer},
),
)
@method_decorator(
name="create",
decorator=swagger_auto_schema(
operation_summary="Create coupon",
operation_description="Creates a coupon (admin only)",
responses={
201: CouponSerializer,
400: ValidationErrorResponseSerializer,
401: ErrorResponse401Serializer,
403: ErrorResponse403Serializer,
},
),
)
@method_decorator(
name="partial_update",
decorator=swagger_auto_schema(
operation_summary="Edit coupon",
operation_description="Edits a coupon by its id (admin only)",
responses={
200: CouponSerializer,
400: ValidationErrorResponseSerializer,
401: ErrorResponse401Serializer,
403: ErrorResponse403Serializer,
404: ErrorResponse404Serializer,
},
),
)
@method_decorator(
name="destroy",
decorator=swagger_auto_schema(
operation_summary="Delete coupon",
operation_description="Deletes a coupon by its id (admin only)",
responses={
200: STATUS_200_RESPONSE_ON_DELETE_IN_DOCS,
401: ErrorResponse401Serializer,
403: ErrorResponse403Serializer,
404: ErrorResponse404Serializer,
},
),
)
class CouponViewSet(DestroyWithPayloadMixin, viewsets.ModelViewSet):
"""Viewset for coupons."""

http_method_names = ["get", "post", "patch", "delete"]
queryset = Coupon.objects.all()
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",
decorator=swagger_auto_schema(
Expand Down Expand Up @@ -597,13 +670,19 @@ 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)
return super().perform_create(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)
Expand Down
1 change: 1 addition & 0 deletions backend/api/recipes_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ class Meta:
"id",
"author",
"name",
"pub_date",
"servings_quantity",
"short_description",
"text",
Expand Down
2 changes: 2 additions & 0 deletions backend/api/reviews_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions backend/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .products_views import (
CategoryViewSet,
ComponentViewSet,
CouponViewSet,
FavoriteProductViewSet,
ProducerViewSet,
ProductViewSet,
Expand All @@ -33,6 +34,7 @@
router.register("tags", TagViewSet)
router.register("producers", ProducerViewSet)
router.register("promotions", PromotionViewSet)
router.register("coupons", CouponViewSet)
router.register("products", ProductViewSet)
router.register(
r"products/(?P<product_id>\d+)/reviews", ReviewViewSet, basename="reviews"
Expand Down
18 changes: 17 additions & 1 deletion backend/api/users_serializers.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -69,6 +77,14 @@ class Meta:
"photo",
)

def validate_birth_date(self, value):
now = timezone.now()
if value and value + relativedelta(years=MIN_USER_AGE) > now.date():
raise serializers.ValidationError(BIRTH_DATE_TOO_YOUNG_ERROR_MESSAGE)
if value and 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()

Expand Down
21 changes: 21 additions & 0 deletions backend/core/management/commands/export_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,19 @@ def export_promotions():
writer.writerow(row)


def export_coupons():
data = apps.get_model("products", "Coupon")
field_names = [f.name for f in data._meta.fields]
with open(
os.path.join(DATA_DIR, "coupons.csv"), "w", newline="", encoding="utf-8"
) as csvfile:
writer = csv.writer(csvfile)
writer.writerow(field_names)
for obj in data.objects.all():
row = [getattr(obj, field) for field in field_names]
writer.writerow(row)


def export_products_promotions():
data = apps.get_model("products", "ProductPromotion")
field_names = ["id", "product_id", "promotion_id"]
Expand Down Expand Up @@ -285,6 +298,8 @@ def export_orders():
"add_address",
"total_price",
"user_data",
"coupon_applied_id",
"coupon_discount",
]
with open(
os.path.join(DATA_DIR, "orders.csv"),
Expand Down Expand Up @@ -357,6 +372,8 @@ def export_recipes():
"image",
"text",
"cooking_time",
"short_description",
"servings_quantity",
]
with open(
os.path.join(DATA_DIR, "recipes.csv"), "w", newline="", encoding="utf-8"
Expand Down Expand Up @@ -426,6 +443,10 @@ def handle(self, *args, **options):
self.stdout.write(
self.style.SUCCESS("Экспорт данных модели Promotion прошёл успешно.")
)
export_coupons()
self.stdout.write(
self.style.SUCCESS("Экспорт данных модели Coupon прошёл успешно.")
)
export_products_promotions()
self.stdout.write(
self.style.SUCCESS("Экспорт данных модели ProductPromoton прошёл успешно.")
Expand Down
Loading