diff --git a/ecommerce/urls.py b/ecommerce/urls.py index 21744e71f8..57e1d15d68 100644 --- a/ecommerce/urls.py +++ b/ecommerce/urls.py @@ -13,7 +13,7 @@ CheckoutApiViewSet, CheckoutCallbackView, CheckoutInterstitialView, - CheckoutProductView, + AddProductToCartView, DiscountViewSet, NestedDiscountProductViewSet, NestedDiscountRedemptionViewSet, @@ -102,6 +102,6 @@ class SimpleRouterWithNesting(NestedRouterMixin, SimpleRouter): CheckoutCallbackView.as_view(), name="checkout-result-callback", ), - re_path(r"^cart/add", CheckoutProductView.as_view(), name="checkout-product"), + re_path(r"^cart/add", AddProductToCartView.as_view(), name="checkout-product"), re_path(r"^int_admin/refund", AdminRefundOrderView.as_view(), name="refund-order"), ] diff --git a/ecommerce/views/v0/__init__.py b/ecommerce/views/v0/__init__.py index 187693044a..99106c18de 100644 --- a/ecommerce/views/v0/__init__.py +++ b/ecommerce/views/v0/__init__.py @@ -11,7 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Count, Q -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import render from django.urls import reverse from django.utils.decorators import method_decorator @@ -661,13 +661,50 @@ def post(self, request, *args, **kwargs): # noqa: ARG002 return Response(status=status.HTTP_200_OK) -class CheckoutProductView(LoginRequiredMixin, RedirectView): - """View to add products to the cart and proceed to the checkout page""" +class AddProductToCartView(APIView): + """View to add products to the cart""" + + def post(self, request, *args, **kwargs): + """Add product to the cart""" + with transaction.atomic(): + basket, _ = Basket.objects.select_for_update().get_or_create( + user=self.request.user + ) + basket.basket_items.all().delete() + BasketDiscount.objects.filter(redeemed_basket=basket).delete() + + # Incoming product ids from internal checkout + all_product_ids = self.request.POST.getlist("product_id") + + # If the request is from an external source we would have course_id as query param + # Note that course_id passed in param corresponds to course run's courseware_id on mitxonline + course_run_ids = self.request.POST.getlist("course_run_id") + course_ids = self.request.POST.getlist("course_id") + program_ids = self.request.POST.getlist("program_id") + + all_product_ids.extend( + list( + CourseRun.objects.filter( + Q(courseware_id__in=course_run_ids) + | Q(courseware_id__in=course_ids) + ).values_list("products__id", flat=True) + ) + ) + all_product_ids.extend( + list( + ProgramRun.objects.filter(program__id__in=program_ids).values_list( + "products__id", flat=True + ) + ) + ) + for product in Product.objects.filter(id__in=all_product_ids): + BasketItem.objects.create(basket=basket, product=product) + + return HttpResponseRedirect(request.headers["Referer"]) - pattern_name = "cart" - def get_redirect_url(self, *args, **kwargs): - """Populate the basket before redirecting""" + def get(self, request, *args, **kwargs): + """Add product to the cart""" with transaction.atomic(): basket, _ = Basket.objects.select_for_update().get_or_create( user=self.request.user @@ -702,7 +739,7 @@ def get_redirect_url(self, *args, **kwargs): for product in Product.objects.filter(id__in=all_product_ids): BasketItem.objects.create(basket=basket, product=product) - return super().get_redirect_url(*args, **kwargs) + return HttpResponseRedirect(request.headers["Referer"]) class CheckoutInterstitialView(LoginRequiredMixin, TemplateView): diff --git a/frontend/public/src/components/CourseProductDetailEnroll.js b/frontend/public/src/components/CourseProductDetailEnroll.js index 9fc1ccae40..5478e3417c 100644 --- a/frontend/public/src/components/CourseProductDetailEnroll.js +++ b/frontend/public/src/components/CourseProductDetailEnroll.js @@ -30,7 +30,7 @@ import { getCookie } from "../lib/api" import users, { currentUserSelector } from "../lib/queries/users" import { enrollmentMutation, - deactivateEnrollmentMutation + deactivateEnrollmentMutation, cartMutation } from "../lib/queries/enrollment" import AddlProfileFieldsForm from "./forms/AddlProfileFieldsForm" import CourseInfoBox from "./CourseInfoBox" @@ -49,12 +49,14 @@ type Props = { addProductToBasket: (user: number, productId: number) => Promise, currentUser: User, createEnrollment: (runId: number) => Promise, + addToCart: (product: Product) => Promise, deactivateEnrollment: (runId: number) => Promise, updateAddlFields: (currentUser: User) => Promise, forceRequest: () => any } type ProductDetailState = { upgradeEnrollmentDialogVisibility: boolean, + addedToCartDialogVisibility: boolean, showAddlProfileFieldsModal: boolean, currentCourseRun: ?EnrollmentFlaggedCourseRun, destinationUrl: string @@ -66,6 +68,7 @@ export class CourseProductDetailEnroll extends React.Component< > { state = { upgradeEnrollmentDialogVisibility: false, + addedToCartDialogVisibility: false, currentCourseRun: null, showAddlProfileFieldsModal: false, destinationUrl: "" @@ -87,6 +90,11 @@ export class CourseProductDetailEnroll extends React.Component< window.open(target, "_blank") } } + toggleCartConfirmationDialogVisibility() { + this.setState({ + addedToCartDialogVisibility: !this.state.addedToCartDialogVisibility + }) + } redirectToCourseHomepage(url: string, ev: any) { /* @@ -226,9 +234,39 @@ export class CourseProductDetailEnroll extends React.Component< } } + renderAddToCartConfirmationDialog() { + const { courses } = this.props + const { addedToCartDialogVisibility } = this.state + const course = courses && courses[0] ? courses[0] : null + return this.cancelEnrollment()} + centered + > + this.cancelEnrollment()}> + Added to Cart + + + {course && course.title} + + + + + } + renderUpgradeEnrollmentDialog() { - const { courses, currentUser } = this.props + const {courses, currentUser} = this.props const courseRuns = courses && courses[0] ? courses[0].courseruns : null + const csrfToken = getCookie("csrftoken") const enrollableCourseRuns = courseRuns ? courseRuns.filter( (run: EnrollmentFlaggedCourseRun) => run.is_enrollable @@ -351,11 +389,13 @@ export class CourseProductDetailEnroll extends React.Component<
+ - +
Add to Cart -
+
to get a Certificate
@@ -533,6 +573,7 @@ export class CourseProductDetailEnroll extends React.Component< {run && currentUser ? this.renderAddlProfileFieldsModal() : null} {this.renderUpgradeEnrollmentDialog()} + {this.renderAddToCartConfirmationDialog()} } @@ -557,6 +598,9 @@ export class CourseProductDetailEnroll extends React.Component< const createEnrollment = (run: EnrollmentFlaggedCourseRun) => mutateAsync(enrollmentMutation(run.id)) +const addToCart = (product: Product) => + mutateAsync(cartMutation(product.id)) + const deactivateEnrollment = (run: number) => mutateAsync(deactivateEnrollmentMutation(run)) @@ -588,6 +632,7 @@ const mapPropsToConfig = props => [ const mapDispatchToProps = { createEnrollment, + addToCart, deactivateEnrollment, updateAddlFields } diff --git a/frontend/public/src/lib/queries/enrollment.js b/frontend/public/src/lib/queries/enrollment.js index d3913ae214..c62ecd913b 100644 --- a/frontend/public/src/lib/queries/enrollment.js +++ b/frontend/public/src/lib/queries/enrollment.js @@ -187,3 +187,16 @@ export const enrollmentMutation = (runId: number) => ({ }, update: {} }) + +export const cartMutation = (productId: number) => ({ + url: `/cart/add/`, + body: { + product_id: `${productId}`, + isapi: true + }, + options: { + ...getCsrfOptions(), + method: "POST" + }, + update: {} +})