Skip to content

Commit c5b7661

Browse files
Add multiple items to cart (#3117)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent a4efed9 commit c5b7661

File tree

9 files changed

+233
-11
lines changed

9 files changed

+233
-11
lines changed

ecommerce/api.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,14 @@ def apply_user_discounts(request):
253253
if BasketDiscount.objects.filter(redeemed_basket=basket).count() > 0:
254254
return
255255

256-
product = BasketItem.objects.get(basket=basket).product
256+
# For multiple items, check each item for flexible pricing discounts
257+
basket_items = BasketItem.objects.filter(basket=basket)
258+
if basket_items.count() == 0:
259+
return
260+
261+
# Use the first item's product for flexible pricing determination
262+
# This maintains backward compatibility while supporting multiple items
263+
product = basket_items.first().product
257264
flexible_price_discount = determine_courseware_flexible_price_discount(
258265
product, user
259266
)

ecommerce/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ def get_products(self):
198198
Returns the products that have been added to the basket so far.
199199
"""
200200

201-
return [item.product for item in self.basket_items.all()]
201+
return [item.product for item in self.basket_items.select_related("product")]
202202

203203

204204
class BasketItem(TimestampedModel):

ecommerce/serializers.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def get_basket_items(self, instance):
178178
"""Get items in the basket"""
179179
return [
180180
BasketItemSerializer(instance=basket, context=self.context).data
181-
for basket in instance.basket_items.all()
181+
for basket in instance.basket_items.select_related("product")
182182
]
183183

184184
class Meta:
@@ -263,14 +263,15 @@ def get_basket_items(self, instance) -> list[dict[str, any]]:
263263
"""
264264
return [
265265
BasketItemWithProductSerializer(instance=basket, context=self.context).data
266-
for basket in instance.basket_items.all()
266+
for basket in instance.basket_items.select_related("product")
267267
]
268268

269269
@extend_schema_field(Decimal)
270270
def get_total_price(self, instance) -> Decimal:
271271
"""Get total price of all items in basket before discounts"""
272272
return sum(
273-
basket_item.base_price for basket_item in instance.basket_items.all()
273+
basket_item.base_price
274+
for basket_item in instance.basket_items.select_related("product")
274275
)
275276

276277
@extend_schema_field(Decimal)
@@ -280,7 +281,8 @@ def get_discounted_price(self, instance) -> Decimal:
280281
if discounts.count() == 0:
281282
return self.get_total_price(instance)
282283
return sum(
283-
basket_item.discounted_price for basket_item in instance.basket_items.all()
284+
basket_item.discounted_price
285+
for basket_item in instance.basket_items.select_related("product")
284286
)
285287

286288
@extend_schema_field(list[BasketDiscountSerializer])
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Tests for multiple cart items functionality"""
2+
3+
import pytest
4+
from django.test import override_settings
5+
from django.urls import reverse
6+
from rest_framework import status
7+
8+
from ecommerce.factories import BasketFactory, BasketItemFactory, ProductFactory
9+
10+
11+
@pytest.mark.django_db
12+
class TestMultipleCartItems:
13+
"""Test class for multiple cart items functionality"""
14+
15+
@override_settings(ENABLE_MULTIPLE_CART_ITEMS=False)
16+
def test_add_to_cart_single_item_mode_default(self, user_drf_client, user):
17+
"""Test that with feature flag disabled, adding items replaces existing items"""
18+
# Create a product and basket with existing item
19+
existing_product = ProductFactory.create()
20+
new_product = ProductFactory.create()
21+
22+
basket = BasketFactory.create(user=user)
23+
BasketItemFactory.create(basket=basket, product=existing_product)
24+
25+
assert basket.basket_items.count() == 1
26+
27+
# Add new product to cart
28+
response = user_drf_client.post(
29+
reverse("checkout_api-add_to_cart"),
30+
data={"product_id": new_product.id},
31+
)
32+
33+
assert response.status_code == status.HTTP_200_OK
34+
assert response.data["message"] == "Product added to cart"
35+
36+
# Verify that only the new product is in the basket
37+
basket.refresh_from_db()
38+
assert basket.basket_items.count() == 1
39+
assert basket.basket_items.first().product == new_product
40+
41+
@override_settings(ENABLE_MULTIPLE_CART_ITEMS=True)
42+
def test_add_to_cart_multiple_items_mode_new_product(self, user_drf_client, user):
43+
"""Test that with feature flag enabled, adding new items keeps existing items"""
44+
# Create products and basket with existing item
45+
existing_product = ProductFactory.create()
46+
new_product = ProductFactory.create()
47+
48+
basket = BasketFactory.create(user=user)
49+
BasketItemFactory.create(basket=basket, product=existing_product)
50+
51+
assert basket.basket_items.count() == 1
52+
53+
# Add new product to cart
54+
response = user_drf_client.post(
55+
reverse("checkout_api-add_to_cart"),
56+
data={"product_id": new_product.id},
57+
)
58+
59+
assert response.status_code == status.HTTP_200_OK
60+
assert response.data["message"] == "Product added to cart"
61+
62+
# Verify that both products are in the basket
63+
basket.refresh_from_db()
64+
assert basket.basket_items.count() == 2
65+
products_in_basket = {item.product for item in basket.basket_items.all()}
66+
assert products_in_basket == {existing_product, new_product}
67+
68+
@override_settings(ENABLE_MULTIPLE_CART_ITEMS=True)
69+
def test_add_to_cart_nonexistent_product(self, user_drf_client):
70+
"""Test that adding a non-existent product returns 404"""
71+
# Add non-existent product to cart
72+
response = user_drf_client.post(
73+
reverse("checkout_api-add_to_cart"),
74+
data={"product_id": 99999},
75+
)
76+
77+
assert response.status_code == status.HTTP_404_NOT_FOUND
78+
assert response.data["message"] == "Product not found"
79+
80+
@override_settings(ENABLE_MULTIPLE_CART_ITEMS=True)
81+
def test_cart_with_multiple_items_pricing(self, user_drf_client, user):
82+
"""Test that cart pricing works correctly with multiple items"""
83+
# Create products with different prices
84+
product1 = ProductFactory.create(price=50.00)
85+
product2 = ProductFactory.create(price=30.00)
86+
87+
basket = BasketFactory.create(user=user)
88+
BasketItemFactory.create(basket=basket, product=product1, quantity=2)
89+
BasketItemFactory.create(basket=basket, product=product2, quantity=1)
90+
91+
# Get cart info
92+
response = user_drf_client.get(reverse("checkout_api-cart"))
93+
94+
assert response.status_code == status.HTTP_200_OK
95+
cart_data = response.data
96+
97+
# Verify total price calculation (2 * $50 + 1 * $30 = $130)
98+
assert float(cart_data["total_price"]) == 130.00
99+
assert len(cart_data["basket_items"]) == 2
100+
101+
def test_basket_items_count_endpoint(self, user_drf_client, user):
102+
"""Test that basket items count endpoint works with multiple items"""
103+
# Create products and add to basket
104+
product1 = ProductFactory.create()
105+
product2 = ProductFactory.create()
106+
107+
basket = BasketFactory.create(user=user)
108+
BasketItemFactory.create(basket=basket, product=product1, quantity=2)
109+
BasketItemFactory.create(basket=basket, product=product2, quantity=1)
110+
111+
# Get basket items count
112+
response = user_drf_client.get(reverse("checkout_api-basket_items_count"))
113+
114+
assert response.status_code == status.HTTP_200_OK
115+
# Should return count of distinct items, not total quantity
116+
assert response.data == 2
117+
118+
@override_settings(ENABLE_MULTIPLE_CART_ITEMS=False)
119+
def test_existing_basket_item_viewset_still_works(self, user_drf_client, user):
120+
"""Test that the existing BasketItemViewSet still works regardless of feature flag"""
121+
product1 = ProductFactory.create()
122+
product2 = ProductFactory.create()
123+
124+
basket = BasketFactory.create(user=user)
125+
BasketItemFactory.create(basket=basket, product=product1)
126+
127+
# Add item using the existing ViewSet endpoint
128+
response = user_drf_client.post(
129+
f"/api/baskets/{basket.id}/items/",
130+
data={"product": product2.id},
131+
)
132+
133+
assert response.status_code == status.HTTP_201_CREATED
134+
135+
# Verify both items are in basket
136+
assert basket.basket_items.count() == 2

ecommerce/views/v0/__init__.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -647,17 +647,44 @@ def add_to_cart(self, request):
647647
basket, _ = Basket.objects.select_for_update().get_or_create(
648648
user=self.request.user
649649
)
650-
basket.basket_items.all().delete()
651-
BasketDiscount.objects.filter(redeemed_basket=basket).delete()
652650

653-
all_product_ids = [request.data["product_id"]]
651+
# Check if multiple cart items feature is enabled
652+
allow_multiple_items = getattr(
653+
settings, "ENABLE_MULTIPLE_CART_ITEMS", False
654+
)
654655

655-
for product in Product.objects.filter(id__in=all_product_ids):
656+
if not allow_multiple_items:
657+
# Legacy behavior: clear existing items and discounts
658+
basket.basket_items.all().delete()
659+
BasketDiscount.objects.filter(redeemed_basket=basket).delete()
660+
else:
661+
# New behavior: only clear discounts, keep existing items
662+
BasketDiscount.objects.filter(redeemed_basket=basket).delete()
663+
664+
product_id = request.data["product_id"]
665+
666+
try:
667+
product = Product.objects.get(id=product_id)
668+
except Product.DoesNotExist:
669+
return Response(
670+
{"message": "Product not found"},
671+
status=status.HTTP_404_NOT_FOUND,
672+
)
673+
message = "Product already in cart"
674+
if allow_multiple_items:
675+
# Check if product already exists in basket
676+
if not basket.basket_items.filter(product=product).exists():
677+
# Add new item to basket
678+
BasketItem.objects.create(basket=basket, product=product)
679+
message = "Product added to cart"
680+
else:
681+
# Legacy behavior: add single item
656682
BasketItem.objects.create(basket=basket, product=product)
683+
message = "Product added to cart"
657684

658685
return Response(
659686
{
660-
"message": "Product added to cart",
687+
"message": message,
661688
}
662689
)
663690

ecommerce/views_test.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,46 @@ def test_checkout_product_with_program_id(user, user_client):
739739
assert [item.product for item in basket.basket_items.all()] == [product]
740740

741741

742+
@pytest.mark.parametrize("multiple_cart_enabled", [True, False])
743+
def test_add_to_cart_api_with_feature_flag(
744+
user_drf_client, user, multiple_cart_enabled, settings
745+
):
746+
"""Test add_to_cart API behavior with and without multiple cart items feature"""
747+
settings.ENABLE_MULTIPLE_CART_ITEMS = multiple_cart_enabled
748+
749+
# Create products
750+
product1 = ProductFactory.create()
751+
product2 = ProductFactory.create()
752+
753+
# Add first product
754+
resp = user_drf_client.post(
755+
reverse("checkout_api-add_to_cart"),
756+
data={"product_id": product1.id},
757+
)
758+
assert resp.status_code == 200
759+
760+
basket = Basket.objects.get(user=user)
761+
assert basket.basket_items.count() == 1
762+
763+
# Add second product
764+
resp = user_drf_client.post(
765+
reverse("checkout_api-add_to_cart"),
766+
data={"product_id": product2.id},
767+
)
768+
assert resp.status_code == 200
769+
770+
basket.refresh_from_db()
771+
if multiple_cart_enabled:
772+
# Should have both products
773+
assert basket.basket_items.count() == 2
774+
products_in_basket = {item.product for item in basket.basket_items.all()}
775+
assert products_in_basket == {product1, product2}
776+
else:
777+
# Should only have the second product (legacy behavior)
778+
assert basket.basket_items.count() == 1
779+
assert basket.basket_items.first().product == product2
780+
781+
742782
def test_discount_rest_api(admin_drf_client, user_drf_client):
743783
"""
744784
Checks that the admin REST API is only accessible by an admin

frontend/public/src/flow/declarations.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ declare type Settings = {
1212
recaptchaKey: ?string,
1313
support_email: string,
1414
features: {
15+
enable_multiple_cart_items?: boolean
1516
},
1617
site_name: string,
1718
zendesk_config: {

main/features.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
IGNORE_EDX_FAILURES = "IGNORE_EDX_FAILURES"
44
SYNC_ON_DASHBOARD_LOAD = "SYNC_ON_DASHBOARD_LOAD"
55
ENABLE_AUTO_DAILY_FEATURED_ITEMS = "mitxonline-auto-daily-featured-items"
6+
ENABLE_MULTIPLE_CART_ITEMS = "ENABLE_MULTIPLE_CART_ITEMS"
67

78
ENABLE_GOOGLE_ANALYTICS_DATA_PUSH = "mitxonline-4099-dedp-google-analytics"

main/settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,14 @@
939939
dev_only=True,
940940
description="The default value for all feature flags",
941941
)
942+
943+
# Multiple cart items feature flag
944+
ENABLE_MULTIPLE_CART_ITEMS = get_bool(
945+
name="ENABLE_MULTIPLE_CART_ITEMS",
946+
default=False,
947+
description="Enable users to add multiple items to their cart/basket",
948+
)
949+
942950
FEATURES = get_features()
943951

944952
# Redis

0 commit comments

Comments
 (0)