diff --git a/core/admin.py b/core/admin.py index 9c63db7f..6b4b573c 100644 --- a/core/admin.py +++ b/core/admin.py @@ -331,6 +331,7 @@ class PromoCodeAdmin(BasicModelAdmin): class PromotionAdmin(BasicModelAdmin, TabbedTranslationAdmin): list_display = ("name", "discount_percent", "modified") search_fields = ("name",) + autocomplete_fields = ("products",) def get_queryset(self, request): qs = super().get_queryset(request) diff --git a/core/docs/drf/viewsets.py b/core/docs/drf/viewsets.py index 45df6ad4..e2b42541 100644 --- a/core/docs/drf/viewsets.py +++ b/core/docs/drf/viewsets.py @@ -15,7 +15,9 @@ from core.serializers import ( AttributeSimpleSerializer, AttributeValueDetailSerializer, AttributeValueSimpleSerializer, + BulkAddOrderProductsSerializer, BulkAddWishlistProductSerializer, + BulkRemoveOrderProductsSerializer, BulkRemoveWishlistProductSerializer, BuyOrderSerializer, BuyUnregisteredOrderSerializer, @@ -196,12 +198,24 @@ ORDER_SCHEMA = { request=AddOrderProductSerializer, responses={status.HTTP_200_OK: OrderDetailSerializer, **BASE_ERRORS}, ), + "bulk_add_order_products": extend_schema( + summary=_("add a list of products to order, quantities will not count"), + description=_("adds a list of products to an order using the provided `product_uuid` and `attributes`."), + request=BulkAddOrderProductsSerializer, + responses={status.HTTP_200_OK: OrderDetailSerializer, **BASE_ERRORS}, + ), "remove_order_product": extend_schema( summary=_("remove product from order"), description=_("removes a product from an order using the provided `product_uuid` and `attributes`."), request=RemoveOrderProductSerializer, responses={status.HTTP_200_OK: OrderDetailSerializer, **BASE_ERRORS}, ), + "bulk_remove_order_products": extend_schema( + summary=_("remove product from order, quantities will not count"), + description=_("removes a list of products from an order using the provided `product_uuid` and `attributes`"), + request=BulkRemoveOrderProductsSerializer, + responses={status.HTTP_200_OK: OrderDetailSerializer, **BASE_ERRORS}, + ), } WISHLIST_SCHEMA = { diff --git a/core/graphene/mutations.py b/core/graphene/mutations.py index 6260b0c5..a7aaa90d 100644 --- a/core/graphene/mutations.py +++ b/core/graphene/mutations.py @@ -11,7 +11,14 @@ from graphene_django.utils import camelize from core.elasticsearch import process_query from core.graphene import BaseMutation -from core.graphene.object_types import AddressType, OrderType, ProductType, SearchResultsType, WishlistType +from core.graphene.object_types import ( + AddressType, + BulkActionOrderProductInput, + OrderType, + ProductType, + SearchResultsType, + WishlistType, +) from core.models import Address, Category, Order, Product, Wishlist from core.utils import format_attributes, is_url_safe from core.utils.caching import web_cache @@ -221,6 +228,52 @@ class BuyOrder(BaseMutation): raise Http404(_(f"order {order_uuid} not found")) +class BulkOrderAction(BaseMutation): + class Meta: + description = _("perform an action on a list of products in the order") + + class Arguments: + order_uuid = UUID(required=False) + order_hr_id = String(required=False) + action = String(required=True, description=_("remove/add")) + products = List(BulkActionOrderProductInput, required=True) + + order = Field(OrderType, required=False) + + @staticmethod + def mutate( + _parent, + info, + action, + products, + order_uuid=None, + order_hr_id=None, + ): + if not any([order_uuid, order_hr_id]) or all([order_uuid, order_hr_id]): + raise BadRequest(_("please provide either order_uuid or order_hr_id - mutually exclusive")) + user = info.context.user + try: + order = None + + if order_uuid: + order = Order.objects.get(user=user, uuid=order_uuid) + elif order_hr_id: + order = Order.objects.get(user=user, human_readable_id=order_hr_id) + + match action: + case "add": + order = order.bulk_add_products(products) + case "remove": + order = order.bulk_remove_products(products) + case _: + raise BadRequest(_("action must be either add or remove")) + + return BulkOrderAction(order=order) + + except Order.DoesNotExist: + raise Http404(_(f"order {order_uuid} not found")) + + class BuyUnregisteredOrder(BaseMutation): class Meta: description = _("purchase an order without account creation") diff --git a/core/graphene/object_types.py b/core/graphene/object_types.py index 86ff703c..ae06b024 100644 --- a/core/graphene/object_types.py +++ b/core/graphene/object_types.py @@ -2,7 +2,7 @@ from django.core.cache import cache from django.db.models import Max, Min, QuerySet from django.db.models.functions import Length from django.utils.translation import gettext_lazy as _ -from graphene import UUID, Field, Float, Int, List, NonNull, ObjectType, String, relay +from graphene import UUID, Field, Float, InputObjectType, Int, List, NonNull, ObjectType, String, relay from graphene.types.generic import GenericScalar from graphene_django import DjangoObjectType from graphene_django.filter import DjangoFilterConnectionField @@ -508,3 +508,8 @@ class SearchResultsType(ObjectType): categories = List(description=_("products search results"), of_type=SearchCategoriesResultsType) brands = List(description=_("products search results"), of_type=SearchBrandsResultsType) posts = List(description=_("posts search results"), of_type=SearchPostsResultsType) + + +class BulkActionOrderProductInput(InputObjectType): + id = UUID(required=True) + attributes = GenericScalar(required=False) diff --git a/core/graphene/schema.py b/core/graphene/schema.py index 5f6947fd..223731fc 100644 --- a/core/graphene/schema.py +++ b/core/graphene/schema.py @@ -19,6 +19,7 @@ from core.graphene.mutations import ( AddOrderProduct, AddWishlistProduct, AutocompleteAddress, + BulkOrderAction, BuyOrder, BuyProduct, BuyWishlist, @@ -294,6 +295,7 @@ class Mutation(ObjectType): remove_all_order_products = RemoveAllOrderProducts.Field() remove_order_products_of_a_kind = RemoveOrderProductsOfAKind.Field() buy_order = BuyOrder.Field() + bulk_order_action = BulkOrderAction.Field() deposit = Deposit.Field() obtain_jwt_token = ObtainJSONWebToken.Field() refresh_jwt_token = RefreshJSONWebToken.Field() diff --git a/core/models.py b/core/models.py index 6b79962b..3b1b64e9 100644 --- a/core/models.py +++ b/core/models.py @@ -546,7 +546,7 @@ class Order(NiceModel): def total_quantity(self) -> int: return sum([op.quantity for op in self.order_products.all()]) - def add_product(self, product_uuid: str | None = None, attributes: list = list): + def add_product(self, product_uuid: str | None = None, attributes: list = list, update_quantity: bool = True): if self.status not in ["PENDING", "MOMENTAL"]: raise ValueError(_("you cannot add products to an order that is not a pending one")) try: @@ -568,7 +568,7 @@ class Order(NiceModel): attributes=json.dumps(attributes), defaults={"quantity": 1, "buy_price": product.price}, ) - if not is_created: + if not is_created and update_quantity: if product.quantity < order_product.quantity + 1: raise BadRequest(_("you cannot add more products than available in stock")) order_product.quantity += 1 @@ -581,12 +581,15 @@ class Order(NiceModel): name = "Product" raise Http404(_(f"{name} does not exist: {product_uuid}")) - def remove_product(self, product_uuid: str | None = None, attributes: dict = dict): + def remove_product(self, product_uuid: str | None = None, attributes: dict = dict, zero_quantity: bool = False): if self.status != "PENDING": raise ValueError(_("you cannot remove products from an order that is not a pending one")) try: product = Product.objects.get(uuid=product_uuid) order_product = self.order_products.get(product=product, order=self) + if zero_quantity: + order_product.delete() + return self if order_product.quantity == 1: self.order_products.remove(order_product) order_product.delete() @@ -778,6 +781,16 @@ class Order(NiceModel): self.status = "FINISHED" self.save() + def bulk_add_products(self, products: list): + for product in products: + self.add_product(product.get("uuid"), attributes=product.get("attributes"), update_quantity=False) + return self + + def bulk_remove_products(self, products: list): + for product in products: + self.remove_product(product.get("uuid"), attributes=product.get("attributes"), zero_quantity=True) + return self + class OrderProduct(NiceModel): is_publicly_visible = False diff --git a/core/serializers/__init__.py b/core/serializers/__init__.py index bc0e72b2..95e372bf 100644 --- a/core/serializers/__init__.py +++ b/core/serializers/__init__.py @@ -55,11 +55,19 @@ class AddOrderProductSerializer(Serializer): attributes = JSONField(required=False, default=dict) +class BulkAddOrderProductsSerializer(Serializer): + products = ListField(child=AddOrderProductSerializer(), required=True) + + class RemoveOrderProductSerializer(Serializer): product_uuid = CharField(required=True) attributes = JSONField(required=False, default=dict) +class BulkRemoveOrderProductsSerializer(Serializer): + products = ListField(child=RemoveOrderProductSerializer(), required=True) + + class AddWishlistProductSerializer(Serializer): product_uuid = CharField(required=True) diff --git a/core/viewsets.py b/core/viewsets.py index 3297bd65..122c7dad 100644 --- a/core/viewsets.py +++ b/core/viewsets.py @@ -60,7 +60,9 @@ from core.serializers import ( AttributeValueSimpleSerializer, BrandDetailSerializer, BrandSimpleSerializer, + BulkAddOrderProductsSerializer, BulkAddWishlistProductSerializer, + BulkRemoveOrderProductsSerializer, BulkRemoveWishlistProductSerializer, BuyOrderSerializer, BuyUnregisteredOrderSerializer, @@ -305,7 +307,7 @@ class OrderViewSet(EvibesViewSet): serializer.is_valid(raise_exception=True) try: order = Order.objects.get(uuid=kwargs.get("pk")) - if not (request.user.has_perm("core.add_orderproduct") or request.user == order.user): + if not (request.user.has_perm("core.delete_orderproduct") or request.user == order.user): raise PermissionDenied(permission_denied_message) order = order.remove_product( @@ -317,6 +319,40 @@ class OrderViewSet(EvibesViewSet): except Order.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) + @action(detail=True, methods=["post"], url_path="bulk_add_order_products") + def bulk_add_order_products(self, request, *_args, **kwargs): + serializer = BulkAddOrderProductsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + order = Order.objects.get(uuid=kwargs.get("pk")) + if not (request.user.has_perm("core.add_orderproduct") or request.user == order.user): + raise PermissionDenied(permission_denied_message) + + order = order.bulk_add_products( + products=serializer.validated_data.get("products"), + ) + + return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data) + except Order.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + @action(detail=True, methods=["post"], url_path="bulk_remove_order_products") + def bulk_remove_order_products(self, request, *_args, **kwargs): + serializer = BulkRemoveOrderProductsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + order = Order.objects.get(uuid=kwargs.get("pk")) + if not (request.user.has_perm("core.delete_orderproduct") or request.user == order.user): + raise PermissionDenied(permission_denied_message) + + order = order.bulk_remove_products( + products=serializer.validated_data.get("products"), + ) + + return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data) + except Order.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + class OrderProductViewSet(EvibesViewSet): queryset = OrderProduct.objects.all()