Features: 1) Add BulkOrderAction mutation to handle bulk addition/removal of order products; 2) Introduce bulk_add_order_products and bulk_remove_order_products endpoints in viewset; 3) Add BulkAddOrderProductsSerializer and BulkRemoveOrderProductsSerializer.

Fixes: 1) Update `remove_product` model method to handle zero quantity removal; 2) Correct permission check in order product removal.

Extra: 1) Add `autocomplete_fields` for products in admin; 2) Enhance docs with bulk add/remove schemas; 3) Various code refactorings and minor tweaks for improved maintainability.
This commit is contained in:
Egor Pavlovich Gorbunov 2025-05-28 14:48:16 +03:00
parent 6e5a008802
commit 4e269dc801
8 changed files with 138 additions and 6 deletions

View file

@ -331,6 +331,7 @@ class PromoCodeAdmin(BasicModelAdmin):
class PromotionAdmin(BasicModelAdmin, TabbedTranslationAdmin): class PromotionAdmin(BasicModelAdmin, TabbedTranslationAdmin):
list_display = ("name", "discount_percent", "modified") list_display = ("name", "discount_percent", "modified")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("products",)
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)

View file

@ -15,7 +15,9 @@ from core.serializers import (
AttributeSimpleSerializer, AttributeSimpleSerializer,
AttributeValueDetailSerializer, AttributeValueDetailSerializer,
AttributeValueSimpleSerializer, AttributeValueSimpleSerializer,
BulkAddOrderProductsSerializer,
BulkAddWishlistProductSerializer, BulkAddWishlistProductSerializer,
BulkRemoveOrderProductsSerializer,
BulkRemoveWishlistProductSerializer, BulkRemoveWishlistProductSerializer,
BuyOrderSerializer, BuyOrderSerializer,
BuyUnregisteredOrderSerializer, BuyUnregisteredOrderSerializer,
@ -196,12 +198,24 @@ ORDER_SCHEMA = {
request=AddOrderProductSerializer, request=AddOrderProductSerializer,
responses={status.HTTP_200_OK: OrderDetailSerializer, **BASE_ERRORS}, 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( "remove_order_product": extend_schema(
summary=_("remove product from order"), summary=_("remove product from order"),
description=_("removes a product from an order using the provided `product_uuid` and `attributes`."), description=_("removes a product from an order using the provided `product_uuid` and `attributes`."),
request=RemoveOrderProductSerializer, request=RemoveOrderProductSerializer,
responses={status.HTTP_200_OK: OrderDetailSerializer, **BASE_ERRORS}, 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 = { WISHLIST_SCHEMA = {

View file

@ -11,7 +11,14 @@ from graphene_django.utils import camelize
from core.elasticsearch import process_query from core.elasticsearch import process_query
from core.graphene import BaseMutation 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.models import Address, Category, Order, Product, Wishlist
from core.utils import format_attributes, is_url_safe from core.utils import format_attributes, is_url_safe
from core.utils.caching import web_cache from core.utils.caching import web_cache
@ -221,6 +228,52 @@ class BuyOrder(BaseMutation):
raise Http404(_(f"order {order_uuid} not found")) 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 BuyUnregisteredOrder(BaseMutation):
class Meta: class Meta:
description = _("purchase an order without account creation") description = _("purchase an order without account creation")

View file

@ -2,7 +2,7 @@ from django.core.cache import cache
from django.db.models import Max, Min, QuerySet from django.db.models import Max, Min, QuerySet
from django.db.models.functions import Length from django.db.models.functions import Length
from django.utils.translation import gettext_lazy as _ 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.types.generic import GenericScalar
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField from graphene_django.filter import DjangoFilterConnectionField
@ -508,3 +508,8 @@ class SearchResultsType(ObjectType):
categories = List(description=_("products search results"), of_type=SearchCategoriesResultsType) categories = List(description=_("products search results"), of_type=SearchCategoriesResultsType)
brands = List(description=_("products search results"), of_type=SearchBrandsResultsType) brands = List(description=_("products search results"), of_type=SearchBrandsResultsType)
posts = List(description=_("posts search results"), of_type=SearchPostsResultsType) posts = List(description=_("posts search results"), of_type=SearchPostsResultsType)
class BulkActionOrderProductInput(InputObjectType):
id = UUID(required=True)
attributes = GenericScalar(required=False)

View file

@ -19,6 +19,7 @@ from core.graphene.mutations import (
AddOrderProduct, AddOrderProduct,
AddWishlistProduct, AddWishlistProduct,
AutocompleteAddress, AutocompleteAddress,
BulkOrderAction,
BuyOrder, BuyOrder,
BuyProduct, BuyProduct,
BuyWishlist, BuyWishlist,
@ -294,6 +295,7 @@ class Mutation(ObjectType):
remove_all_order_products = RemoveAllOrderProducts.Field() remove_all_order_products = RemoveAllOrderProducts.Field()
remove_order_products_of_a_kind = RemoveOrderProductsOfAKind.Field() remove_order_products_of_a_kind = RemoveOrderProductsOfAKind.Field()
buy_order = BuyOrder.Field() buy_order = BuyOrder.Field()
bulk_order_action = BulkOrderAction.Field()
deposit = Deposit.Field() deposit = Deposit.Field()
obtain_jwt_token = ObtainJSONWebToken.Field() obtain_jwt_token = ObtainJSONWebToken.Field()
refresh_jwt_token = RefreshJSONWebToken.Field() refresh_jwt_token = RefreshJSONWebToken.Field()

View file

@ -546,7 +546,7 @@ class Order(NiceModel):
def total_quantity(self) -> int: def total_quantity(self) -> int:
return sum([op.quantity for op in self.order_products.all()]) 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"]: if self.status not in ["PENDING", "MOMENTAL"]:
raise ValueError(_("you cannot add products to an order that is not a pending one")) raise ValueError(_("you cannot add products to an order that is not a pending one"))
try: try:
@ -568,7 +568,7 @@ class Order(NiceModel):
attributes=json.dumps(attributes), attributes=json.dumps(attributes),
defaults={"quantity": 1, "buy_price": product.price}, 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: if product.quantity < order_product.quantity + 1:
raise BadRequest(_("you cannot add more products than available in stock")) raise BadRequest(_("you cannot add more products than available in stock"))
order_product.quantity += 1 order_product.quantity += 1
@ -581,12 +581,15 @@ class Order(NiceModel):
name = "Product" name = "Product"
raise Http404(_(f"{name} does not exist: {product_uuid}")) 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": if self.status != "PENDING":
raise ValueError(_("you cannot remove products from an order that is not a pending one")) raise ValueError(_("you cannot remove products from an order that is not a pending one"))
try: try:
product = Product.objects.get(uuid=product_uuid) product = Product.objects.get(uuid=product_uuid)
order_product = self.order_products.get(product=product, order=self) order_product = self.order_products.get(product=product, order=self)
if zero_quantity:
order_product.delete()
return self
if order_product.quantity == 1: if order_product.quantity == 1:
self.order_products.remove(order_product) self.order_products.remove(order_product)
order_product.delete() order_product.delete()
@ -778,6 +781,16 @@ class Order(NiceModel):
self.status = "FINISHED" self.status = "FINISHED"
self.save() 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): class OrderProduct(NiceModel):
is_publicly_visible = False is_publicly_visible = False

View file

@ -55,11 +55,19 @@ class AddOrderProductSerializer(Serializer):
attributes = JSONField(required=False, default=dict) attributes = JSONField(required=False, default=dict)
class BulkAddOrderProductsSerializer(Serializer):
products = ListField(child=AddOrderProductSerializer(), required=True)
class RemoveOrderProductSerializer(Serializer): class RemoveOrderProductSerializer(Serializer):
product_uuid = CharField(required=True) product_uuid = CharField(required=True)
attributes = JSONField(required=False, default=dict) attributes = JSONField(required=False, default=dict)
class BulkRemoveOrderProductsSerializer(Serializer):
products = ListField(child=RemoveOrderProductSerializer(), required=True)
class AddWishlistProductSerializer(Serializer): class AddWishlistProductSerializer(Serializer):
product_uuid = CharField(required=True) product_uuid = CharField(required=True)

View file

@ -60,7 +60,9 @@ from core.serializers import (
AttributeValueSimpleSerializer, AttributeValueSimpleSerializer,
BrandDetailSerializer, BrandDetailSerializer,
BrandSimpleSerializer, BrandSimpleSerializer,
BulkAddOrderProductsSerializer,
BulkAddWishlistProductSerializer, BulkAddWishlistProductSerializer,
BulkRemoveOrderProductsSerializer,
BulkRemoveWishlistProductSerializer, BulkRemoveWishlistProductSerializer,
BuyOrderSerializer, BuyOrderSerializer,
BuyUnregisteredOrderSerializer, BuyUnregisteredOrderSerializer,
@ -305,7 +307,7 @@ class OrderViewSet(EvibesViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try: try:
order = Order.objects.get(uuid=kwargs.get("pk")) 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) raise PermissionDenied(permission_denied_message)
order = order.remove_product( order = order.remove_product(
@ -317,6 +319,40 @@ class OrderViewSet(EvibesViewSet):
except Order.DoesNotExist: except Order.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) 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): class OrderProductViewSet(EvibesViewSet):
queryset = OrderProduct.objects.all() queryset = OrderProduct.objects.all()