def test_mode_for_seat(self, certificate_type, id_verification_required, mode): """ Verify the correct enrollment mode is returned for a given seat. """ course = Course.objects.create(id='edx/Demo_Course/DemoX') toggle_switch(ENROLLMENT_CODE_SWITCH, True) seat = course.create_or_update_seat(certificate_type, id_verification_required, 10.00, self.partner) self.assertEqual(mode_for_seat(seat), mode) enrollment_code = course.enrollment_code_product if enrollment_code: # We should only have enrollment codes for allowed types self.assertEqual(mode_for_seat(enrollment_code), mode)
def assert_correct_event_payload(self, instance, event_payload, order_number, currency, total): """ Check that field values in the event payload correctly represent the completed order or refund. """ self.assertEqual(['currency', 'orderId', 'products', 'total'], sorted(event_payload.keys())) self.assertEqual(event_payload['orderId'], order_number) self.assertEqual(event_payload['currency'], currency) lines = instance.lines.all() self.assertEqual(len(lines), len(event_payload['products'])) model_name = instance.__class__.__name__ tracked_products_dict = { product['id']: product for product in event_payload['products'] } if model_name == 'Order': self.assertEqual(event_payload['total'], str(total)) for line in lines: tracked_product = tracked_products_dict.get(line.partner_sku) self.assertIsNotNone(tracked_product) self.assertEqual(line.product.course.id, tracked_product['name']) self.assertEqual(str(line.line_price_excl_tax), tracked_product['price']) self.assertEqual(line.quantity, tracked_product['quantity']) self.assertEqual(mode_for_seat(line.product), tracked_product['sku']) self.assertEqual(line.product.get_product_class().name, tracked_product['category']) elif model_name == 'Refund': self.assertEqual(event_payload['total'], '-{}'.format(total)) for line in lines: tracked_product = tracked_products_dict.get( line.order_line.partner_sku) self.assertIsNotNone(tracked_product) self.assertEqual(line.order_line.product.course.id, tracked_product['name']) self.assertEqual(str(line.line_credit_excl_tax), tracked_product['price']) self.assertEqual(-1 * line.quantity, tracked_product['quantity']) self.assertEqual(mode_for_seat(line.order_line.product), tracked_product['sku']) self.assertEqual( line.order_line.product.get_product_class().name, tracked_product['category']) else: # Payload validation is currently limited to order and refund events self.fail()
def process_checkout_complete(sender, order=None, request=None, user=None, **kwargs): # pylint: disable=unused-argument """Tell Sailthru when payment done. Arguments: Parameters described at http://django-oscar.readthedocs.io/en/releases-1.1/ref/signals.html """ if not waffle.switch_is_active('sailthru_enable'): return # loop through lines in order # If multi product orders become common it may be worthwhile to pass an array of # orders to the worker in one call to save overhead, however, that would be difficult # because of the fact that there are different templates for free enroll versus paid enroll for line in order.lines.all(): # get product product = line.product # get price price = line.line_price_excl_tax course_id = product.course_id # figure out course url course_url = _build_course_url(course_id) # pass event to ecommerce_worker.sailthru.v1.tasks to handle asynchronously update_course_enrollment.delay(user.email, course_url, False, mode_for_seat(product), unit_cost=price, course_id=course_id, currency=order.currency, site_code=request.site.siteconfiguration.partner.short_code, message_id=request.COOKIES.get('sailthru_bid'))
def process_basket_addition(sender, product=None, request=None, user=None, **kwargs): # pylint: disable=unused-argument """Tell Sailthru when payment started. Arguments: Parameters described at http://django-oscar.readthedocs.io/en/releases-1.1/ref/signals.html """ if not waffle.switch_is_active('sailthru_enable'): return course_id = product.course_id # figure out course url course_url = _build_course_url(course_id) # get price & currency stock_record = product.stockrecords.first() if stock_record: price = stock_record.price_excl_tax currency = stock_record.price_currency # return if no price, no need to add free items to shopping cart if not price: return # pass event to ecommerce_worker.sailthru.v1.tasks to handle asynchronously update_course_enrollment.delay(user.email, course_url, True, mode_for_seat(product), unit_cost=price, course_id=course_id, currency=currency, site_code=request.site.siteconfiguration.partner.short_code, message_id=request.COOKIES.get('sailthru_bid'))
def track_completed_order(sender, order=None, **kwargs): # pylint: disable=unused-argument """Emit a tracking event when an order is placed.""" if not (is_segment_configured() and order.total_excl_tax > 0): return user_tracking_id, lms_client_id = parse_tracking_context(order.user) analytics.track( user_tracking_id, 'Completed Order', { 'orderId': order.number, 'total': str(order.total_excl_tax), 'currency': order.currency, 'products': [ { # For backwards-compatibility with older events the `sku` field is (ab)used to # store the product's `certificate_type`, while the `id` field holds the product's # SKU. Marketing is aware that this approach will not scale once we start selling # products other than courses, and will need to change in the future. 'id': line.partner_sku, 'sku': mode_for_seat(line.product), 'name': line.product.course.id, 'price': str(line.line_price_excl_tax), 'quantity': line.quantity, 'category': line.product.get_product_class().name, } for line in order.lines.all() ], }, context={ 'Google Analytics': { 'clientId': lms_client_id } }, )
def track_completed_order(sender, order=None, **kwargs): # pylint: disable=unused-argument """Emit a tracking event when an order is placed.""" if order.total_excl_tax <= 0: return properties = { 'orderId': order.number, 'total': str(order.total_excl_tax), 'currency': order.currency, 'products': [ { # For backwards-compatibility with older events the `sku` field is (ab)used to # store the product's `certificate_type`, while the `id` field holds the product's # SKU. Marketing is aware that this approach will not scale once we start selling # products other than courses, and will need to change in the future. 'id': line.partner_sku, 'sku': mode_for_seat(line.product), 'name': line.product.course.id if line.product.course else line.product.title, 'price': str(line.line_price_excl_tax), 'quantity': line.quantity, 'category': line.product.get_product_class().name, } for line in order.lines.all() ], } track_segment_event(order.site, order.user, 'Order Completed', properties)
def test_load_from_lms(self): """ Verify the method creates new objects based on data loaded from the LMS. """ with mock.patch.object(LMSPublisher, 'publish') as mock_publish: mock_publish.return_value = True migrated_course = self._migrate_course_from_lms() course = migrated_course.course # Verify that the migrated course was not published back to the LMS self.assertFalse(mock_publish.called) # Ensure LMS was called with the correct headers for request in httpretty.httpretty.latest_requests: self.assert_lms_api_headers(request) # Verify created objects match mocked data parent_seat = course.parent_seat_product self.assertEqual(parent_seat.title, 'Seat in {}'.format(self.course_name)) self.assertEqual(course.verification_deadline, EXPIRES) for seat in course.seat_products: mode = mode_for_seat(seat) logger.info('Validating objects for [%s] mode...', mode) self.assert_stock_record_valid(seat.stockrecords.first(), seat, Decimal(self.prices[mode]))
def process_basket_addition(sender, product=None, user=None, request=None, **kwargs): # pylint: disable=unused-argument """Tell Sailthru when payment started. Arguments: Parameters described at http://django-oscar.readthedocs.io/en/releases-1.1/ref/signals.html """ if not waffle.switch_is_active('sailthru_enable'): return course_id = product.course_id # figure out course url course_url = _build_course_url(course_id) # get price & currency stock_record = product.stockrecords.first() if stock_record: price = stock_record.price_excl_tax currency = stock_record.price_currency # return if no price, no need to add free items to shopping cart if not price: return # pass event to ecommerce_worker.sailthru.v1.tasks to handle asynchronously update_course_enrollment.delay(user.email, course_url, True, mode_for_seat(product), unit_cost=price, course_id=course_id, currency=currency, site_code=request.site.siteconfiguration.partner.short_code, message_id=request.COOKIES.get('sailthru_bid'))
def test_fall_back_to_course_structure(self): """ Verify that migration falls back to the Course Structure API when data is unavailable from the Commerce API. """ self._mock_lms_apis() body = {'detail': 'Not found'} httpretty.register_uri(httpretty.GET, self.commerce_api_url, status=404, body=json.dumps(body), content_type=JSON) migrated_course = MigratedCourse(self.course_id, self.site.domain) migrated_course.load_from_lms() course = migrated_course.course # Verify that created objects match mocked data. parent_seat = course.parent_seat_product self.assertEqual(parent_seat.title, 'Seat in {}'.format(self.course_name)) # Confirm that there is no verification deadline set for the course. self.assertEqual(course.verification_deadline, None) for seat in course.seat_products: mode = mode_for_seat(seat) self.assert_stock_record_valid(seat.stockrecords.first(), seat, Decimal(self.prices[mode]))
def test_mode_for_seat(self, certificate_type, id_verification_required, mode): """ Verify the correct enrollment mode is returned for a given seat. """ course = Course.objects.create(id='edx/Demo_Course/DemoX') seat = course.create_or_update_seat(certificate_type, id_verification_required, 10.00, self.partner) self.assertEqual(mode_for_seat(seat), mode)
def test_enrollment_module_fulfill(self, parse_tracking_context): """Happy path test to ensure we can properly fulfill enrollments.""" httpretty.register_uri(httpretty.POST, get_lms_enrollment_api_url(), status=200, body='{}', content_type=JSON) parse_tracking_context.return_value = ('user_123', 'GA-123456789', '11.22.33.44') # Attempt to enroll. with LogCapture(LOGGER_NAME) as l: EnrollmentFulfillmentModule().fulfill_product( self.order, list(self.order.lines.all())) line = self.order.lines.get() l.check(( LOGGER_NAME, 'INFO', 'line_fulfilled: course_id="{}", credit_provider="{}", mode="{}", order_line_id="{}", ' 'order_number="{}", product_class="{}", user_id="{}"'.format( line.product.attr.course_key, None, mode_for_seat(line.product), line.id, line.order.number, line.product.get_product_class().name, line.order.user.id, ))) self.assertEqual(LINE.COMPLETE, line.status) last_request = httpretty.last_request() actual_body = json.loads(last_request.body) actual_headers = last_request.headers expected_body = { 'user': self.order.user.username, 'is_active': True, 'mode': self.certificate_type, 'course_details': { 'course_id': self.course_id, }, 'enrollment_attributes': [{ 'namespace': 'order', 'name': 'order_number', 'value': self.order.number }] } expected_headers = { 'X-Edx-Ga-Client-Id': 'GA-123456789', 'X-Forwarded-For': '11.22.33.44', } self.assertDictContainsSubset(expected_headers, actual_headers) self.assertEqual(expected_body, actual_body)
def process_checkout_complete( sender, order=None, user=None, request=None, # pylint: disable=unused-argument response=None, **kwargs): # pylint: disable=unused-argument """Tell Sailthru when payment done. Arguments: Parameters described at http://django-oscar.readthedocs.io/en/releases-1.1/ref/signals.html """ if not waffle.switch_is_active('sailthru_enable'): return partner = order.site.siteconfiguration.partner if not partner.enable_sailthru: return # get campaign id from cookies, or saved value in basket message_id = None if request: message_id = request.COOKIES.get('sailthru_bid') if not message_id: saved_id = BasketAttribute.objects.filter( basket=order.basket, attribute_type=get_basket_attribute_type()) if len(saved_id) > 0: message_id = saved_id[0].value_text # loop through lines in order # If multi product orders become common it may be worthwhile to pass an array of # orders to the worker in one call to save overhead, however, that would be difficult # because of the fact that there are different templates for free enroll versus paid enroll for line in order.lines.all(): # get product product = line.product # ignore everything except course seats. no support for coupons as of yet product_class_name = product.get_product_class().name if product_class_name == SEAT_PRODUCT_CLASS_NAME: price = line.line_price_excl_tax course_id = product.course_id # Tell Sailthru that the purchase is complete asynchronously update_course_enrollment.delay(order.user.email, _build_course_url(course_id), False, mode_for_seat(product), unit_cost=price, course_id=course_id, currency=order.currency, site_code=partner.short_code, message_id=message_id)
def get(self, request): partner = get_partner_for_site(request) sku = request.GET.get('sku', None) code = request.GET.get('code', None) if not sku: return HttpResponseBadRequest(_('No SKU provided.')) if code: voucher, __ = get_voucher_from_code(code=code) else: voucher = None try: product = StockRecord.objects.get(partner=partner, partner_sku=sku).product course_key = product.attr.course_key api = EdxRestApiClient( get_lms_enrollment_base_api_url(), oauth_access_token=request.user.access_token, append_slash=False ) logger.debug( 'Getting enrollment information for [%s] in [%s].', request.user.username, course_key ) status = api.enrollment(','.join([request.user.username, course_key])).get() username = request.user.username seat_type = mode_for_seat(product) if status and status.get('mode') == seat_type and status.get('is_active'): logger.warning( 'User [%s] attempted to repurchase the [%s] seat of course [%s]', username, seat_type, course_key ) return HttpResponseBadRequest(_('You are already enrolled in {course}.').format( course=product.course.name)) except StockRecord.DoesNotExist: return HttpResponseBadRequest(_('SKU [{sku}] does not exist.').format(sku=sku)) except (ConnectionError, SlumberBaseException, Timeout) as ex: logger.exception( 'Failed to retrieve enrollment details for [%s] in course [%s], Because of [%s]', request.user.username, course_key, ex, ) return HttpResponseBadRequest(_('An error occurred while retrieving enrollment details. Please try again.')) purchase_info = request.strategy.fetch_for_product(product) if not purchase_info.availability.is_available_to_buy: return HttpResponseBadRequest(_('Product [{product}] not available to buy.').format(product=product.title)) prepare_basket(request, product, voucher) return HttpResponseRedirect(reverse('basket:summary'), status=303)
def serialize_seat_for_commerce_api(self, seat): """ Serializes a course seat product to a dict that can be further serialized to JSON. """ stock_record = seat.stockrecords.first() return { 'name': mode_for_seat(seat), 'currency': stock_record.price_currency, 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'expires': self.get_seat_expiration(seat), }
def test_enrollment_module_fulfill(self, parse_tracking_context): """Happy path test to ensure we can properly fulfill enrollments.""" httpretty.register_uri(httpretty.POST, get_lms_enrollment_api_url(), status=200, body='{}', content_type=JSON) parse_tracking_context.return_value = ('user_123', 'GA-123456789', '11.22.33.44') # Attempt to enroll. with LogCapture(LOGGER_NAME) as l: EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all())) line = self.order.lines.get() l.check( ( LOGGER_NAME, 'INFO', 'line_fulfilled: course_id="{}", credit_provider="{}", mode="{}", order_line_id="{}", ' 'order_number="{}", product_class="{}", user_id="{}"'.format( line.product.attr.course_key, None, mode_for_seat(line.product), line.id, line.order.number, line.product.get_product_class().name, line.order.user.id, ) ) ) self.assertEqual(LINE.COMPLETE, line.status) last_request = httpretty.last_request() actual_body = json.loads(last_request.body) actual_headers = last_request.headers expected_body = { 'user': self.order.user.username, 'is_active': True, 'mode': self.certificate_type, 'course_details': { 'course_id': self.course_id, }, 'enrollment_attributes': [ { 'namespace': 'order', 'name': 'order_number', 'value': self.order.number } ] } expected_headers = { 'X-Edx-Ga-Client-Id': 'GA-123456789', 'X-Forwarded-For': '11.22.33.44', } self.assertDictContainsSubset(expected_headers, actual_headers) self.assertEqual(expected_body, actual_body)
def test_credit_enrollment_module_fulfill(self): """Happy path test to ensure we can properly fulfill enrollments.""" # Create the credit certificate type and order for the credit certificate type. self.create_seat_and_order(certificate_type='credit', provider='MIT') httpretty.register_uri(httpretty.POST, get_lms_enrollment_api_url(), status=200, body='{}', content_type=JSON) # Attempt to enroll. with LogCapture(LOGGER_NAME) as l: EnrollmentFulfillmentModule().fulfill_product( self.order, list(self.order.lines.all())) line = self.order.lines.get() l.check(( LOGGER_NAME, 'INFO', 'line_fulfilled: course_id="{}", credit_provider="{}", mode="{}", order_line_id="{}", ' 'order_number="{}", product_class="{}", user_id="{}"'.format( line.product.attr.course_key, line.product.attr.credit_provider, mode_for_seat(line.product), line.id, line.order.number, line.product.get_product_class().name, line.order.user.id, ))) self.assertEqual(LINE.COMPLETE, line.status) actual = json.loads(httpretty.last_request().body) expected = { 'user': self.order.user.username, 'is_active': True, 'mode': self.certificate_type, 'course_details': { 'course_id': self.course_id, }, 'enrollment_attributes': [{ 'namespace': 'order', 'name': 'order_number', 'value': self.order.number }, { 'namespace': 'credit', 'name': 'provider_id', 'value': self.provider }] } self.assertEqual(actual, expected)
def assert_correct_event_payload(self, instance, event_payload, order_number, currency, total): """ Check that field values in the event payload correctly represent the completed order or refund. """ self.assertEqual(['currency', 'orderId', 'products', 'total'], sorted(event_payload.keys())) self.assertEqual(event_payload['orderId'], order_number) self.assertEqual(event_payload['currency'], currency) lines = instance.lines.all() self.assertEqual(len(lines), len(event_payload['products'])) model_name = instance.__class__.__name__ tracked_products_dict = {product['id']: product for product in event_payload['products']} if model_name == 'Order': self.assertEqual(event_payload['total'], str(total)) for line in lines: tracked_product = tracked_products_dict.get(line.partner_sku) self.assertIsNotNone(tracked_product) self.assertEqual(line.product.course.id, tracked_product['name']) self.assertEqual(str(line.line_price_excl_tax), tracked_product['price']) self.assertEqual(line.quantity, tracked_product['quantity']) self.assertEqual(mode_for_seat(line.product), tracked_product['sku']) self.assertEqual(line.product.get_product_class().name, tracked_product['category']) elif model_name == 'Refund': self.assertEqual(event_payload['total'], '-{}'.format(total)) for line in lines: tracked_product = tracked_products_dict.get(line.order_line.partner_sku) self.assertIsNotNone(tracked_product) self.assertEqual(line.order_line.product.course.id, tracked_product['name']) self.assertEqual(str(line.line_credit_excl_tax), tracked_product['price']) self.assertEqual(-1 * line.quantity, tracked_product['quantity']) self.assertEqual(mode_for_seat(line.order_line.product), tracked_product['sku']) self.assertEqual(line.order_line.product.get_product_class().name, tracked_product['category']) else: # Payload validation is currently limited to order and refund events self.fail()
def process_basket_addition(sender, product=None, user=None, request=None, basket=None, **kwargs): # pylint: disable=unused-argument """Tell Sailthru when payment started. Arguments: Parameters described at http://django-oscar.readthedocs.io/en/releases-1.1/ref/signals.html """ if not waffle.switch_is_active('sailthru_enable'): return partner = request.site.siteconfiguration.partner if not partner.enable_sailthru: return # ignore everything except course seats. no support for coupons as of yet product_class_name = product.get_product_class().name if product_class_name == SEAT_PRODUCT_CLASS_NAME: course_id = product.course_id stock_record = product.stockrecords.first() if stock_record: price = stock_record.price_excl_tax currency = stock_record.price_currency # save Sailthru campaign ID, if there is one message_id = request.COOKIES.get('sailthru_bid') if message_id and basket: BasketAttribute.objects.update_or_create( basket=basket, attribute_type=get_basket_attribute_type(), defaults={'value_text': message_id}) # inform sailthru if there is a price. The purpose of this call is to tell Sailthru when # an item has been added to the shopping cart so that an abandoned cart message can be sent # later if the purchase is not completed. Abandoned cart support is only for purchases, not # for free enrolls if price: update_course_enrollment.delay(user.email, _build_course_url(course_id), True, mode_for_seat(product), unit_cost=price, course_id=course_id, currency=currency, site_code=partner.short_code, message_id=message_id)
def prepare_basket(request, products, voucher=None): """ Create or get the basket, add products, apply a voucher, and record referral data. Existing baskets are merged. Specified products will be added to the remaining open basket. If voucher is passed, all existing vouchers added to the basket are removed because we allow only one voucher per basket. Vouchers are not applied if an enrollment code product is in the basket. Arguments: request (Request): The request object made to the view. products (List): List of products to be added to the basket. voucher (Voucher): Voucher to apply to the basket. Returns: basket (Basket): Contains the product to be redeemed and the Voucher applied. """ basket = Basket.get_basket(request.user, request.site) basket.flush() basket.save() basket_addition = get_class('basket.signals', 'basket_addition') already_purchased_products = [] for product in products: if product.is_enrollment_code_product or \ not UserAlreadyPlacedOrder.user_already_placed_order(request.user, product): basket.add_product(product, 1) # Call signal handler to notify listeners that something has been added to the basket basket_addition.send(sender=basket_addition, product=product, user=request.user, request=request, basket=basket) else: already_purchased_products.append(product) logger.warning( 'User [%s] attempted to repurchase the [%s] seat of course [%s]', request.user.username, mode_for_seat(product), product.course_id) if already_purchased_products and basket.is_empty: raise AlreadyPlacedOrderException if len(products) == 1 and products[0].is_enrollment_code_product: basket.clear_vouchers() elif voucher: basket.clear_vouchers() basket.vouchers.add(voucher) Applicator().apply(basket, request.user, request) logger.info('Applied Voucher [%s] to basket [%s].', voucher.code, basket.id) attribute_cookie_data(basket, request) return basket
def get(self, request): partner = get_partner_for_site(request) sku = request.GET.get('sku', None) code = request.GET.get('code', None) if not sku: return HttpResponseBadRequest(_('No SKU provided.')) voucher = Voucher.objects.get(code=code) if code else None try: product = StockRecord.objects.get(partner=partner, partner_sku=sku).product except StockRecord.DoesNotExist: return HttpResponseBadRequest( _('SKU [{sku}] does not exist.').format(sku=sku)) if voucher is None: # Find and apply the enterprise entitlement on the learner basket voucher = get_entitlement_voucher(request, product) # If the product isn't available then there's no reason to continue with the basket addition purchase_info = request.strategy.fetch_for_product(product) if not purchase_info.availability.is_available_to_buy: msg = _('Product [{product}] not available to buy.').format( product=product.title) return HttpResponseBadRequest(msg) # If the product is not an Enrollment Code, we check to see if the user is already # enrolled to prevent double-enrollment and/or accidental coupon usage if product.get_product_class( ).name != ENROLLMENT_CODE_PRODUCT_CLASS_NAME: try: if request.user.is_user_already_enrolled(request, product): logger.warning( 'User [%s] attempted to repurchase the [%s] seat of course [%s]', request.user.username, mode_for_seat(product), product.attr.course_key) msg = _('You are already enrolled in {course}.').format( course=product.course.name) return HttpResponseBadRequest(msg) except (ConnectionError, SlumberBaseException, Timeout): msg = _( 'An error occurred while retrieving enrollment details. Please try again.' ) return HttpResponseBadRequest(msg) # At this point we're either adding an Enrollment Code product to the basket, # or the user is adding a Seat product for which they are not already enrolled prepare_basket(request, product, voucher) return HttpResponseRedirect(reverse('basket:summary'), status=303)
def test_credit_enrollment_module_fulfill(self): """Happy path test to ensure we can properly fulfill enrollments.""" # Create the credit certificate type and order for the credit certificate type. self.create_seat_and_order(certificate_type='credit', provider='MIT') httpretty.register_uri(httpretty.POST, get_lms_enrollment_api_url(), status=200, body='{}', content_type=JSON) # Attempt to enroll. with LogCapture(LOGGER_NAME) as l: EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all())) line = self.order.lines.get() l.check( ( LOGGER_NAME, 'INFO', 'line_fulfilled: course_id="{}", credit_provider="{}", mode="{}", order_line_id="{}", ' 'order_number="{}", product_class="{}", user_id="{}"'.format( line.product.attr.course_key, line.product.attr.credit_provider, mode_for_seat(line.product), line.id, line.order.number, line.product.get_product_class().name, line.order.user.id, ) ) ) self.assertEqual(LINE.COMPLETE, line.status) actual = json.loads(httpretty.last_request().body) expected = { 'user': self.order.user.username, 'is_active': True, 'mode': self.certificate_type, 'course_details': { 'course_id': self.course_id, }, 'enrollment_attributes': [ { 'namespace': 'order', 'name': 'order_number', 'value': self.order.number }, { 'namespace': 'credit', 'name': 'provider_id', 'value': self.provider } ] } self.assertEqual(actual, expected)
def mock_program_detail_endpoint(self, program_uuid): """ Mocks the program detail endpoint on the Catalog API. Args: program_uuid (uuid): UUID of the mocked program. Returns: dict: Mocked program data. """ courses = [] for __ in range(1, 5): course_runs = [] for __ in range(1, 4): course_run = CourseFactory() course_run.create_or_update_seat('audit', False, Decimal(0), self.partner) course_run.create_or_update_seat('verified', True, Decimal(100), self.partner) course_runs.append({ 'key': course_run.id, 'seats': [{ 'type': mode_for_seat(seat), 'sku': seat.stockrecords.get( partner=self.partner).partner_sku, } for seat in course_run.seat_products] }) courses.append({ 'course_runs': course_runs, }) program_uuid = str(program_uuid) data = { 'uuid': program_uuid, 'title': 'Test Program', 'type': 'MicroMockers', 'courses': courses, 'applicable_seat_types': ['verified', 'professional', 'credit'], } self.mock_access_token_response() httpretty.register_uri( method=httpretty.GET, uri='{base}/programs/{uuid}/'.format( base=settings.COURSE_CATALOG_API_URL.strip('/'), uuid=program_uuid), body=json.dumps(data), content_type='application/json') return data
def process_checkout_complete(sender, order=None, user=None, request=None, # pylint: disable=unused-argument response=None, **kwargs): # pylint: disable=unused-argument """Tell Sailthru when payment done. Arguments: Parameters described at http://django-oscar.readthedocs.io/en/releases-1.1/ref/signals.html """ if not waffle.switch_is_active('sailthru_enable'): return partner = order.site.siteconfiguration.partner if not partner.enable_sailthru: return # get campaign id from cookies, or saved value in basket message_id = None if request: message_id = request.COOKIES.get('sailthru_bid') if not message_id: saved_id = BasketAttribute.objects.filter( basket=order.basket, attribute_type=get_basket_attribute_type() ) if len(saved_id) > 0: message_id = saved_id[0].value_text # loop through lines in order # If multi product orders become common it may be worthwhile to pass an array of # orders to the worker in one call to save overhead, however, that would be difficult # because of the fact that there are different templates for free enroll versus paid enroll for line in order.lines.all(): # get product product = line.product # ignore everything except course seats. no support for coupons as of yet product_class_name = product.get_product_class().name if product_class_name == SEAT_PRODUCT_CLASS_NAME: price = line.line_price_excl_tax course_id = product.course_id # Tell Sailthru that the purchase is complete asynchronously update_course_enrollment.delay(order.user.email, _build_course_url(course_id), False, mode_for_seat(product), unit_cost=price, course_id=course_id, currency=order.currency, site_code=partner.short_code, message_id=message_id)
def revoke_line(self, line): try: logger.info('Attempting to revoke fulfillment of Line [%d]...', line.id) mode = mode_for_seat(line.product) course_key = line.product.attr.course_key data = { 'user': line.order.user.username, 'is_active': False, 'mode': mode, 'course_details': { 'course_id': course_key, }, } response = self._post_to_enrollment_api(data, user=line.order.user) if response.status_code == status.HTTP_200_OK: audit_log('line_revoked', order_line_id=line.id, order_number=line.order.number, product_class=line.product.get_product_class().name, course_id=course_key, certificate_type=getattr(line.product.attr, 'certificate_type', ''), user_id=line.order.user.id) return True else: # check if the error / message are something we can recover from. data = response.json() detail = data.get('message', '(No details provided.)') if response.status_code == 400 and "Enrollment mode mismatch" in detail: # The user is currently enrolled in different mode than the one # we are refunding an order for. Don't revoke that enrollment. logger.info('Skipping revocation for line [%d]: %s', line.id, detail) return True else: logger.error( 'Failed to revoke fulfillment of Line [%d]: %s', line.id, detail) except Exception: # pylint: disable=broad-except logger.exception('Failed to revoke fulfillment of Line [%d].', line.id) return False
def serialize_seat_for_commerce_api(self, seat): """ Serializes a course seat product to a dict that can be further serialized to JSON. """ stock_record = seat.stockrecords.first() try: enrollment_code = seat.course.enrollment_code_product bulk_sku = enrollment_code.stockrecords.first().partner_sku except Product.DoesNotExist: bulk_sku = None return { 'name': mode_for_seat(seat), 'currency': stock_record.price_currency, 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'bulk_sku': bulk_sku, 'expires': self.get_seat_expiration(seat), }
def get(self, request): partner = get_partner_for_site(request) sku = request.GET.get('sku', None) code = request.GET.get('code', None) if not sku: return HttpResponseBadRequest(_('No SKU provided.')) voucher = Voucher.objects.get(code=code) if code else None try: product = StockRecord.objects.get(partner=partner, partner_sku=sku).product except StockRecord.DoesNotExist: return HttpResponseBadRequest(_('SKU [{sku}] does not exist.').format(sku=sku)) if voucher is None: # Find and apply the enterprise entitlement on the learner basket voucher = get_entitlement_voucher(request, product) # If the product isn't available then there's no reason to continue with the basket addition purchase_info = request.strategy.fetch_for_product(product) if not purchase_info.availability.is_available_to_buy: msg = _('Product [{product}] not available to buy.').format(product=product.title) return HttpResponseBadRequest(msg) # If the product is not an Enrollment Code, we check to see if the user is already # enrolled to prevent double-enrollment and/or accidental coupon usage if product.get_product_class().name != ENROLLMENT_CODE_PRODUCT_CLASS_NAME: try: if request.user.is_user_already_enrolled(request, product): logger.warning( 'User [%s] attempted to repurchase the [%s] seat of course [%s]', request.user.username, mode_for_seat(product), product.attr.course_key ) msg = _('You are already enrolled in {course}.').format(course=product.course.name) return HttpResponseBadRequest(msg) except (ConnectionError, SlumberBaseException, Timeout): msg = _('An error occurred while retrieving enrollment details. Please try again.') return HttpResponseBadRequest(msg) # At this point we're either adding an Enrollment Code product to the basket, # or the user is adding a Seat product for which they are not already enrolled prepare_basket(request, product, voucher) return HttpResponseRedirect(reverse('basket:summary'), status=303)
def revoke_line(self, line): try: logger.info('Attempting to revoke fulfillment of Line [%d]...', line.id) mode = mode_for_seat(line.product) course_key = line.product.attr.course_key data = { 'user': line.order.user.username, 'is_active': False, 'mode': mode, 'course_details': { 'course_id': course_key, }, } __, client_id = parse_tracking_context(line.order.user) response = self._post_to_enrollment_api(data, client_id=client_id) if response.status_code == status.HTTP_200_OK: audit_log( 'line_revoked', order_line_id=line.id, order_number=line.order.number, product_class=line.product.get_product_class().name, course_id=course_key, certificate_type=getattr(line.product.attr, 'certificate_type', ''), user_id=line.order.user.id ) return True else: # check if the error / message are something we can recover from. data = response.json() detail = data.get('message', '(No details provided.)') if response.status_code == 400 and "Enrollment mode mismatch" in detail: # The user is currently enrolled in different mode than the one # we are refunding an order for. Don't revoke that enrollment. logger.info('Skipping revocation for line [%d]: %s', line.id, detail) return True else: logger.error('Failed to revoke fulfillment of Line [%d]: %s', line.id, detail) except Exception: # pylint: disable=broad-except logger.exception('Failed to revoke fulfillment of Line [%d].', line.id) return False
def serialize_seat_for_commerce_api(self, seat): """ Serializes a course seat product to a dict that can be further serialized to JSON. """ stock_record = seat.stockrecords.first() bulk_sku = None if getattr(seat.attr, 'certificate_type', '') in ENROLLMENT_CODE_SEAT_TYPES: enrollment_code = seat.course.enrollment_code_product if enrollment_code: bulk_sku = enrollment_code.stockrecords.first().partner_sku return { 'name': mode_for_seat(seat), 'currency': stock_record.price_currency, 'price': int(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'bulk_sku': bulk_sku, 'expires': self.get_seat_expiration(seat), }
def assert_course_migrated(self): """ Verify the course was migrated and saved to the database. """ course = Course.objects.get(id=self.course_id) seats = course.seat_products # Verify that all modes are migrated. self.assertEqual(len(seats), len(self.prices)) parent = course.products.get(structure=Product.PARENT) self.assertEqual(list(parent.categories.all()), [self.category]) for seat in seats: mode = mode_for_seat(seat) logger.info('Validating objects for [%s] mode...', mode) stock_record = self.partner.stockrecords.get(product=seat) self.assert_seat_valid(seat, mode) self.assert_stock_record_valid(stock_record, seat, self.prices[mode])
def track_completed_refund(sender, refund=None, **kwargs): # pylint: disable=unused-argument """Emit a tracking event when a refund is completed.""" if not (is_segment_configured() and refund.total_credit_excl_tax > 0): return user_tracking_id, lms_client_id, lms_ip = parse_tracking_context( refund.user) # Ecommerce transaction reversal, performed by emitting an event which is the inverse of an # order completion event emitted previously. # See: https://support.google.com/analytics/answer/1037443?hl=en refund.order.site.siteconfiguration.segment_client.track( user_tracking_id, 'Completed Order', { 'orderId': refund.order.number, 'total': '-{}'.format(refund.total_credit_excl_tax), 'currency': refund.currency, 'products': [ { # For backwards-compatibility with older events the `sku` field is (ab)used to # store the product's `certificate_type`, while the `id` field holds the product's # SKU. Marketing is aware that this approach will not scale once we start selling # products other than courses, and will need to change in the future. 'id': line.order_line.partner_sku, 'sku': mode_for_seat(line.order_line.product), 'name': line.order_line.product.course.id, 'price': str(line.line_credit_excl_tax), 'quantity': -1 * line.quantity, 'category': line.order_line.product.get_product_class().name, } for line in refund.lines.all() ], }, context={ 'ip': lms_ip, 'Google Analytics': { 'clientId': lms_client_id } }, )
def track_completed_order(sender, order=None, **kwargs): # pylint: disable=unused-argument """Emit a tracking event when an order is placed.""" if not (is_segment_configured() and order.total_excl_tax > 0): return user_tracking_id, lms_client_id, lms_ip = parse_tracking_context( order.user) order.site.siteconfiguration.segment_client.track( user_tracking_id, 'Completed Order', { 'orderId': order.number, 'total': str(order.total_excl_tax), 'currency': order.currency, 'products': [ { # For backwards-compatibility with older events the `sku` field is (ab)used to # store the product's `certificate_type`, while the `id` field holds the product's # SKU. Marketing is aware that this approach will not scale once we start selling # products other than courses, and will need to change in the future. 'id': line.partner_sku, 'sku': mode_for_seat(line.product), 'name': line.product.course.id, 'price': str(line.line_price_excl_tax), 'quantity': line.quantity, 'category': line.product.get_product_class().name, } for line in order.lines.all() ], }, context={ 'ip': lms_ip, 'Google Analytics': { 'clientId': lms_client_id } }, )
def is_user_already_enrolled(self, request, seat): """ Check if a user is already enrolled in the course. Calls the LMS enrollment API endpoint and sends the course ID and username query parameters and returns the status of the user's enrollment in the course. Arguments: request (WSGIRequest): the request from which the LMS enrollment API endpoint is created. seat (Product): the seat for which the check is done if the user is enrolled in. Returns: A boolean value if the user is enrolled in the course or not. Raises: ConnectionError, SlumberBaseException and Timeout for failures in establishing a connection with the LMS enrollment API endpoint. """ course_key = seat.attr.course_key try: api = EdxRestApiClient( request.site.siteconfiguration.build_lms_url( '/api/enrollment/v1'), oauth_access_token=self.access_token, append_slash=False) status = api.enrollment(','.join([self.username, course_key])).get() except (ConnectionError, SlumberBaseException, Timeout) as ex: log.exception( 'Failed to retrieve enrollment details for [%s] in course [%s], because of [%s]', self.username, course_key, ex, ) raise ex seat_type = mode_for_seat(seat) if status and status.get('mode') == seat_type and status.get( 'is_active'): return True return False
def translate_basket_line_for_segment(line): """ Translates a BasketLine to Segment's expected format for cart events. Args: line (BasketLine) Returns: dict """ course = line.product.course return { # For backwards-compatibility with older events the `sku` field is (ab)used to # store the product's `certificate_type`, while the `id` field holds the product's # SKU. Marketing is aware that this approach will not scale once we start selling # products other than courses, and will need to change in the future. 'product_id': line.stockrecord.partner_sku, 'sku': mode_for_seat(line.product), 'name': course.id if course else line.product.title, 'price': str(line.line_price_excl_tax), 'quantity': line.quantity, 'category': line.product.get_product_class().name, }
def track_completed_refund(sender, refund=None, **kwargs): # pylint: disable=unused-argument """Emit a tracking event when a refund is completed.""" if not (is_segment_configured() and refund.total_credit_excl_tax > 0): return user_tracking_id, lms_client_id, lms_ip = parse_tracking_context(refund.user) # Ecommerce transaction reversal, performed by emitting an event which is the inverse of an # order completion event emitted previously. # See: https://support.google.com/analytics/answer/1037443?hl=en refund.order.site.siteconfiguration.segment_client.track( user_tracking_id, 'Completed Order', { 'orderId': refund.order.number, 'total': '-{}'.format(refund.total_credit_excl_tax), 'currency': refund.currency, 'products': [ { # For backwards-compatibility with older events the `sku` field is (ab)used to # store the product's `certificate_type`, while the `id` field holds the product's # SKU. Marketing is aware that this approach will not scale once we start selling # products other than courses, and will need to change in the future. 'id': line.order_line.partner_sku, 'sku': mode_for_seat(line.order_line.product), 'name': line.order_line.product.course.id, 'price': str(line.line_credit_excl_tax), 'quantity': -1 * line.quantity, 'category': line.order_line.product.get_product_class().name, } for line in refund.lines.all() ], }, context={ 'ip': lms_ip, 'Google Analytics': { 'clientId': lms_client_id } }, )
def test_enrollment_module_fulfill(self): """Happy path test to ensure we can properly fulfill enrollments.""" httpretty.register_uri(httpretty.POST, settings.ENROLLMENT_API_URL, status=200, body='{}', content_type=JSON) # Attempt to enroll. with LogCapture(LOGGER_NAME) as l: EnrollmentFulfillmentModule().fulfill_product(self.order, list(self.order.lines.all())) line = self.order.lines.get() l.check( ( LOGGER_NAME, 'INFO', 'line_fulfilled: course_id="{}", credit_provider="{}", mode="{}", order_line_id="{}", ' 'order_number="{}", product_class="{}", user_id="{}"'.format( line.product.attr.course_key, None, mode_for_seat(line.product), line.id, line.order.number, line.product.get_product_class().name, line.order.user.id, ) ) ) self.assertEqual(LINE.COMPLETE, line.status) actual = json.loads(httpretty.last_request().body) expected = { 'user': self.order.user.username, 'is_active': True, 'mode': self.certificate_type, 'course_details': { 'course_id': self.course_id, }, 'enrollment_attributes': [] } self.assertEqual(actual, expected)
def test_fall_back_to_course_structure(self): """ Verify that migration falls back to the Course Structure API when data is unavailable from the Commerce API. """ self._mock_lms_apis() body = {'detail': 'Not found'} httpretty.register_uri(httpretty.GET, self.commerce_api_url, status=404, body=json.dumps(body), content_type=JSON) migrated_course = MigratedCourse(self.course_id, self.partner.short_code) migrated_course.load_from_lms(ACCESS_TOKEN) course = migrated_course.course # Ensure that the LMS was called with the correct headers. course_structure_path = urlparse(self.course_structure_url).path for request in httpretty.httpretty.latest_requests: if request.path == course_structure_path: self.assert_lms_api_headers(request, bearer=True) else: self.assert_lms_api_headers(request) # Verify that created objects match mocked data. parent_seat = course.parent_seat_product self.assertEqual(parent_seat.title, 'Seat in {}'.format(self.course_name)) # Confirm that there is no verification deadline set for the course. self.assertEqual(course.verification_deadline, None) for seat in course.seat_products: mode = mode_for_seat(seat) self.assert_stock_record_valid(seat.stockrecords.first(), seat, Decimal(self.prices[mode]))
def is_user_already_enrolled(self, request, seat): """ Check if a user is already enrolled in the course. Calls the LMS enrollment API endpoint and sends the course ID and username query parameters and returns the status of the user's enrollment in the course. Arguments: request (WSGIRequest): the request from which the LMS enrollment API endpoint is created. seat (Product): the seat for which the check is done if the user is enrolled in. Returns: A boolean value if the user is enrolled in the course or not. Raises: ConnectionError, SlumberBaseException and Timeout for failures in establishing a connection with the LMS enrollment API endpoint. """ course_key = seat.attr.course_key try: api = EdxRestApiClient( request.site.siteconfiguration.build_lms_url('/api/enrollment/v1'), oauth_access_token=self.access_token, append_slash=False ) status = api.enrollment(','.join([self.username, course_key])).get() except (ConnectionError, SlumberBaseException, Timeout) as ex: log.exception( 'Failed to retrieve enrollment details for [%s] in course [%s], Because of [%s]', self.username, course_key, ex, ) raise ex seat_type = mode_for_seat(seat) if status and status.get('mode') == seat_type and status.get('is_active'): return True return False
def process_checkout_complete(sender, order=None, user=None, request=None, # pylint: disable=unused-argument response=None, **kwargs): # pylint: disable=unused-argument """Tell Sailthru when payment done. Arguments: Parameters described at http://django-oscar.readthedocs.io/en/releases-1.1/ref/signals.html """ if not waffle.switch_is_active('sailthru_enable'): return message_id = None if request: message_id = request.COOKIES.get('sailthru_bid') # loop through lines in order # If multi product orders become common it may be worthwhile to pass an array of # orders to the worker in one call to save overhead, however, that would be difficult # because of the fact that there are different templates for free enroll versus paid enroll for line in order.lines.all(): # get product product = line.product # get price price = line.line_price_excl_tax course_id = product.course_id # figure out course url course_url = _build_course_url(course_id) # pass event to ecommerce_worker.sailthru.v1.tasks to handle asynchronously update_course_enrollment.delay(order.user.email, course_url, False, mode_for_seat(product), unit_cost=price, course_id=course_id, currency=order.currency, site_code=order.site.siteconfiguration.partner.short_code, message_id=message_id)
def test_fall_back_to_course_structure(self): """ Verify that migration falls back to the Course Structure API when data is unavailable from the Commerce API. """ self._mock_lms_apis() body = {'detail': 'Not found'} httpretty.register_uri( httpretty.GET, self.commerce_api_url, status=404, body=json.dumps(body), content_type=JSON ) migrated_course = MigratedCourse(self.course_id, self.partner.short_code) migrated_course.load_from_lms(ACCESS_TOKEN) course = migrated_course.course # Ensure that the LMS was called with the correct headers. course_structure_path = urlparse(self.course_structure_url).path for request in httpretty.httpretty.latest_requests: if request.path == course_structure_path: self.assert_lms_api_headers(request, bearer=True) else: self.assert_lms_api_headers(request) # Verify that created objects match mocked data. parent_seat = course.parent_seat_product self.assertEqual(parent_seat.title, 'Seat in {}'.format(self.course_name)) # Confirm that there is no verification deadline set for the course. self.assertEqual(course.verification_deadline, None) for seat in course.seat_products: mode = mode_for_seat(seat) self.assert_stock_record_valid(seat.stockrecords.first(), seat, Decimal(self.prices[mode]))
def _generate_event_properties(self, order): return { 'orderId': order.number, 'total': str(order.total_excl_tax), 'currency': order.currency, 'products': [{ 'id': line.partner_sku, 'sku': mode_for_seat(line.product), 'name': line.product.course.id if line.product.course else line.product.title, 'price': str(line.line_price_excl_tax), 'quantity': line.quantity, 'category': line.product.get_product_class().name, } for line in order.lines.all()], }
def fulfill_product(self, order, lines): """ Fulfills the purchase of a 'seat' by enrolling the associated student. Uses the order and the lines to determine which courses to enroll a student in, and with certain certificate types. May result in an error if the Enrollment API cannot be reached, or if there is additional business logic errors when trying to enroll the student. Args: order (Order): The Order associated with the lines to be fulfilled. The user associated with the order is presumed to be the student to enroll in a course. lines (List of Lines): Order Lines, associated with purchased products in an Order. These should only be "Seat" products. Returns: The original set of lines, with new statuses set based on the success or failure of fulfillment. """ logger.info("Attempting to fulfill 'Seat' product types for order [%s]", order.number) enrollment_api_url = getattr(settings, 'ENROLLMENT_API_URL', None) api_key = getattr(settings, 'EDX_API_KEY', None) if not (enrollment_api_url and api_key): logger.error( 'ENROLLMENT_API_URL and EDX_API_KEY must be set to use the EnrollmentFulfillmentModule' ) for line in lines: line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR) return order, lines for line in lines: try: mode = mode_for_seat(line.product) course_key = line.product.attr.course_key except AttributeError: logger.error("Supported Seat Product does not have required attributes, [certificate_type, course_key]") line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR) continue try: provider = line.product.attr.credit_provider except AttributeError: logger.debug("Seat [%d] has no credit_provider attribute. Defaulted to None.", line.product.id) provider = None data = { 'user': order.user.username, 'is_active': True, 'mode': mode, 'course_details': { 'course_id': course_key }, 'enrollment_attributes': [] } if provider: data['enrollment_attributes'].append( { 'namespace': 'credit', 'name': 'provider_id', 'value': provider } ) try: response = self._post_to_enrollment_api(data, user=order.user) if response.status_code == status.HTTP_200_OK: line.set_status(LINE.COMPLETE) audit_log( 'line_fulfilled', order_line_id=line.id, order_number=order.number, product_class=line.product.get_product_class().name, course_id=course_key, mode=mode, user_id=order.user.id, credit_provider=provider, ) else: try: data = response.json() reason = data.get('message') except Exception: # pylint: disable=broad-except reason = '(No detail provided.)' logger.error( "Unable to fulfill line [%d] of order [%s] due to a server-side error: %s", line.id, order.number, reason ) line.set_status(LINE.FULFILLMENT_SERVER_ERROR) except ConnectionError: logger.error( "Unable to fulfill line [%d] of order [%s] due to a network problem", line.id, order.number ) line.set_status(LINE.FULFILLMENT_NETWORK_ERROR) except Timeout: logger.error( "Unable to fulfill line [%d] of order [%s] due to a request time out", line.id, order.number ) line.set_status(LINE.FULFILLMENT_TIMEOUT_ERROR) logger.info("Finished fulfilling 'Seat' product types for order [%s]", order.number) return order, lines
def get(self, request): partner = get_partner_for_site(request) sku = request.GET.get('sku', None) code = request.GET.get('code', None) if not sku: return HttpResponseBadRequest(_('No SKU provided.')) if code: voucher, __ = get_voucher_and_products_from_code(code=code) else: voucher = None try: product = StockRecord.objects.get(partner=partner, partner_sku=sku).product except StockRecord.DoesNotExist: return HttpResponseBadRequest(_('SKU [{sku}] does not exist.').format(sku=sku)) # If the product isn't available then there's no reason to continue with the basket addition purchase_info = request.strategy.fetch_for_product(product) if not purchase_info.availability.is_available_to_buy: msg = _('Product [{product}] not available to buy.').format(product=product.title) return HttpResponseBadRequest(msg) # If the product is not an Enrollment Code, we check to see if the user is already # enrolled to prevent double-enrollment and/or accidental coupon usage if product.get_product_class().name != ENROLLMENT_CODE_PRODUCT_CLASS_NAME: course_key = product.attr.course_key # Submit a query to the LMS Enrollment API try: api = EdxRestApiClient( get_lms_enrollment_base_api_url(), oauth_access_token=request.user.access_token, append_slash=False ) logger.debug( 'Getting enrollment information for [%s] in [%s].', request.user.username, course_key ) status = api.enrollment(','.join([request.user.username, course_key])).get() except (ConnectionError, SlumberBaseException, Timeout) as ex: logger.exception( 'Failed to retrieve enrollment details for [%s] in course [%s], Because of [%s]', request.user.username, course_key, ex, ) msg = _('An error occurred while retrieving enrollment details. Please try again.') return HttpResponseBadRequest(msg) # Enrollment API response received, now perform the actual enrollment check username = request.user.username seat_type = mode_for_seat(product) if status and status.get('mode') == seat_type and status.get('is_active'): logger.warning( 'User [%s] attempted to repurchase the [%s] seat of course [%s]', username, seat_type, course_key ) msg = _('You are already enrolled in {course}.').format(course=product.course.name) return HttpResponseBadRequest(msg) # At this point we're either adding an Enrollment Code product to the basket, # or the user is adding a Seat product for which they are not already enrolled prepare_basket(request, product, voucher) return HttpResponseRedirect(reverse('basket:summary'), status=303)
def process_basket_addition(sender, product=None, user=None, request=None, basket=None, **kwargs): # pylint: disable=unused-argument """Tell Sailthru when payment started. Arguments: Parameters described at http://django-oscar.readthedocs.io/en/releases-1.1/ref/signals.html """ if not waffle.switch_is_active('sailthru_enable'): return partner = request.site.siteconfiguration.partner if not partner.enable_sailthru: return # ignore everything except course seats. no support for coupons as of yet product_class_name = product.get_product_class().name if product_class_name == SEAT_PRODUCT_CLASS_NAME: course_id = product.course_id stock_record = product.stockrecords.first() if stock_record: price = stock_record.price_excl_tax currency = stock_record.price_currency # save Sailthru campaign ID, if there is one message_id = request.COOKIES.get('sailthru_bid') if message_id and basket: BasketAttribute.objects.update_or_create( basket=basket, attribute_type=get_basket_attribute_type(), defaults={'value_text': message_id} ) # inform sailthru if there is a price. The purpose of this call is to tell Sailthru when # an item has been added to the shopping cart so that an abandoned cart message can be sent # later if the purchase is not completed. Abandoned cart support is only for purchases, not # for free enrolls if price: update_course_enrollment.delay(user.email, _build_course_url(course_id), True, mode_for_seat(product), unit_cost=price, course_id=course_id, currency=currency, site_code=partner.short_code, message_id=message_id)
def get(self, request): partner = get_partner_for_site(request) sku = request.GET.get('sku', None) code = request.GET.get('code', None) consent_failed = request.GET.get(CONSENT_FAILED_PARAM, False) if not sku: return HttpResponseBadRequest(_('No SKU provided.')) voucher = Voucher.objects.get(code=code) if code else None try: product = StockRecord.objects.get(partner=partner, partner_sku=sku).product except StockRecord.DoesNotExist: return HttpResponseBadRequest( _('SKU [{sku}] does not exist.').format(sku=sku)) if not consent_failed and voucher is None: # Find and apply the enterprise entitlement on the learner basket. First, check two things: # 1. We don't already have an existing voucher parsed from a URL parameter # 2. The `consent_failed` URL parameter is falsey, or missing, meaning that we haven't already # attempted to apply an Enterprise voucher at least once, but the user rejected consent. Failing # to make that check would result in the user being repeatedly prompted to grant consent for the # same coupon they already declined consent on. voucher = get_entitlement_voucher(request, product) if voucher is not None: params = urlencode( OrderedDict([ ('code', voucher.code), ('sku', sku), # This view does not handle getting data sharing consent. However, the coupon redemption # view does. By adding the `failure_url` parameter, we're informing that view that, in the # event required consent for a coupon can't be collected, the user ought to be directed # back to this single-item basket view, with the `consent_failed` parameter applied so that # we know not to try to apply the enterprise coupon again. ( 'failure_url', request.build_absolute_uri( '{path}?{params}'.format( path=reverse('basket:single-item'), params=urlencode( OrderedDict([ (CONSENT_FAILED_PARAM, True), ('sku', sku), ])))), ), ])) return HttpResponseRedirect('{path}?{params}'.format( path=reverse('coupons:redeem'), params=params)) # If the product isn't available then there's no reason to continue with the basket addition purchase_info = request.strategy.fetch_for_product(product) if not purchase_info.availability.is_available_to_buy: msg = _('Product [{product}] not available to buy.').format( product=product.title) return HttpResponseBadRequest(msg) # If the product is not an Enrollment Code and this is a Coupon Redemption request, # we check to see if the user is already enrolled # to prevent double-enrollment and/or accidental coupon usage. if not product.is_enrollment_code_product and code: try: if request.user.is_user_already_enrolled(request, product): logger.warning( 'User [%s] attempted to repurchase the [%s] seat of course [%s]', request.user.username, mode_for_seat(product), product.attr.course_key) msg = _('You are already enrolled in {course}.').format( course=product.course.name) return HttpResponseBadRequest(msg) except (ConnectionError, SlumberBaseException, Timeout): msg = _( 'An error occurred while retrieving enrollment details. Please try again.' ) return HttpResponseBadRequest(msg) # At this point we're either adding an Enrollment Code product to the basket, # or the user is adding a Seat product for which they are not already enrolled prepare_basket(request, [product], voucher) return HttpResponseRedirect(reverse('basket:summary'), status=303)
def fulfill_product(self, order, lines): """ Fulfills the purchase of a 'seat' by enrolling the associated student. Uses the order and the lines to determine which courses to enroll a student in, and with certain certificate types. May result in an error if the Enrollment API cannot be reached, or if there is additional business logic errors when trying to enroll the student. Args: order (Order): The Order associated with the lines to be fulfilled. The user associated with the order is presumed to be the student to enroll in a course. lines (List of Lines): Order Lines, associated with purchased products in an Order. These should only be "Seat" products. Returns: The original set of lines, with new statuses set based on the success or failure of fulfillment. """ logger.info( "Attempting to fulfill 'Seat' product types for order [%s]", order.number) api_key = getattr(settings, 'EDX_API_KEY', None) if not api_key: logger.error( 'EDX_API_KEY must be set to use the EnrollmentFulfillmentModule' ) for line in lines: line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR) return order, lines for line in lines: try: mode = mode_for_seat(line.product) course_key = line.product.attr.course_key except AttributeError: logger.error( "Supported Seat Product does not have required attributes, [certificate_type, course_key]" ) line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR) continue try: provider = line.product.attr.credit_provider except AttributeError: logger.debug( "Seat [%d] has no credit_provider attribute. Defaulted to None.", line.product.id) provider = None data = { 'user': order.user.username, 'is_active': True, 'mode': mode, 'course_details': { 'course_id': course_key }, 'enrollment_attributes': [{ 'namespace': 'order', 'name': 'order_number', 'value': order.number }] } if provider: data['enrollment_attributes'].append({ 'namespace': 'credit', 'name': 'provider_id', 'value': provider }) try: response = self._post_to_enrollment_api(data, user=order.user) if response.status_code == status.HTTP_200_OK: line.set_status(LINE.COMPLETE) audit_log( 'line_fulfilled', order_line_id=line.id, order_number=order.number, product_class=line.product.get_product_class().name, course_id=course_key, mode=mode, user_id=order.user.id, credit_provider=provider, ) else: try: data = response.json() reason = data.get('message') except Exception: # pylint: disable=broad-except reason = '(No detail provided.)' logger.error( "Unable to fulfill line [%d] of order [%s] due to a server-side error: %s", line.id, order.number, reason) line.set_status(LINE.FULFILLMENT_SERVER_ERROR) except ConnectionError: logger.error( "Unable to fulfill line [%d] of order [%s] due to a network problem", line.id, order.number) line.set_status(LINE.FULFILLMENT_NETWORK_ERROR) except Timeout: logger.error( "Unable to fulfill line [%d] of order [%s] due to a request time out", line.id, order.number) line.set_status(LINE.FULFILLMENT_TIMEOUT_ERROR) logger.info("Finished fulfilling 'Seat' product types for order [%s]", order.number) return order, lines
def test_mode_for_seat(self, certificate_type, id_verification_required, mode): """ Verify the correct enrollment mode is returned for a given seat. """ course = Course.objects.create(id='edx/Demo_Course/DemoX') seat = course.create_or_update_seat(certificate_type, id_verification_required, 10.00) self.assertEqual(mode_for_seat(seat), mode)