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:
parent
6e5a008802
commit
4e269dc801
8 changed files with 138 additions and 6 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue