def test_mode_for_product(self, certificate_type, id_verification_required, mode): """ Verify the correct enrollment mode is returned for a given seat. """ course = CourseFactory(id='edx/Demo_Course/DemoX', partner=self.partner) seat = course.create_or_update_seat(certificate_type, id_verification_required, 10.00) self.assertEqual(mode_for_product(seat), mode) enrollment_code = course.enrollment_code_product if enrollment_code: # We should only have enrollment codes for allowed types self.assertEqual(mode_for_product(enrollment_code), mode)
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 logger: EnrollmentFulfillmentModule().fulfill_product( self.order, list(self.order.lines.all())) line = self.order.lines.get() logger.check_present(( 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_product(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.decode('utf-8')) 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': 'order', 'name': 'date_placed', 'value': self.order.date_placed.strftime(ISO_8601_FORMAT) }, { 'namespace': 'credit', 'name': 'provider_id', 'value': self.provider }] } self.assertEqual(actual, expected)
def mock_program_detail_endpoint(self, program_uuid, discovery_api_url, empty=False, title='Test Program'): """ Mocks the program detail endpoint on the Catalog API. Args: program_uuid (uuid): UUID of the mocked program. Returns: dict: Mocked program data. """ data = None if not empty: courses = [] for i in range(1, 5): key = 'course-v1:test-org+course+' + str(i) 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_product(seat), 'sku': seat.stockrecords.get( partner=self.partner).partner_sku, } for seat in course_run.seat_products] }) courses.append({ 'key': key, 'course_runs': course_runs, }) program_uuid = str(program_uuid) data = { 'uuid': program_uuid, 'title': title, '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=discovery_api_url.strip('/'), uuid=program_uuid), body=json.dumps(data), content_type='application/json') return data
def test_entitlement_module_fulfill(self): """ Test to ensure we can properly fulfill course entitlements. """ self.mock_access_token_response() httpretty.register_uri(httpretty.POST, get_lms_entitlement_api_url() + 'entitlements/', status=200, body=json.dumps(self.return_data), content_type='application/json') # Attempt to fulfill entitlement. with LogCapture(LOGGER_NAME) as l: CourseEntitlementFulfillmentModule().fulfill_product( self.order, list(self.order.lines.all())) line = self.order.lines.get() l.check( (LOGGER_NAME, 'INFO', 'line_fulfilled: UUID="{}", mode="{}", order_line_id="{}", ' 'order_number="{}", product_class="{}", user_id="{}"'.format( line.product.attr.UUID, mode_for_product(line.product), line.id, line.order.number, line.product.get_product_class().name, line.order.user.id, ))) course_entitlement_uuid = line.attributes.get( option=self.entitlement_option).value self.assertEqual(course_entitlement_uuid, '111-222-333') self.assertEqual(LINE.COMPLETE, line.status)
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_product(seat) self.assert_stock_record_valid(seat.stockrecords.first(), seat, Decimal(self.prices[mode]))
def _generate_event_properties(self, order, voucher=None, bundle_id=None, fullBundle=False): coupon = voucher.code if voucher else None properties = { 'orderId': order.number, 'total': float(order.total_excl_tax), 'revenue': float(order.total_excl_tax), 'currency': order.currency, 'coupon': coupon, 'discount': float(order.total_discount_incl_tax), 'products': [{ 'id': line.partner_sku, 'sku': mode_for_product(line.product), 'name': line.product.course.id if line.product.course else line.product.title, 'price': float(line.line_price_excl_tax), 'quantity': line.quantity, 'category': line.product.get_product_class().name, 'title': line.product.title, } for line in order.lines.all()], } if order.user: properties['email'] = order.user.email if bundle_id: program = self.mock_get_program_data(fullBundle) if len(order.lines.all()) < len(program['courses']): variant = 'partial' else: variant = 'full' bundle_product = { 'id': bundle_id, 'price': 0, 'quantity': len(order.lines.all()), 'category': 'bundle', 'variant': variant, 'name': program['title'] } properties['products'].append(bundle_product) return properties
def test_enrollment_module_fulfill(self): """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) # 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_product(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': self.user.tracking_context['ga_client_id'], 'X-Forwarded-For': self.user.tracking_context['lms_ip'], } self.assertDictContainsSubset(expected_headers, actual_headers) self.assertEqual(expected_body, actual_body)
def process_basket_addition(sender, product=None, user=None, request=None, basket=None, is_multi_product_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 site_configuration = request.site.siteconfiguration if not site_configuration.enable_sailthru: return # ignore everything except course seats. no support for coupons as of yet if product.is_seat_product: 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 and not is_multi_product_basket: update_course_enrollment.delay( user.email, _build_course_url(course_id), True, mode_for_product(product), unit_cost=price, course_id=course_id, currency=currency, site_code=site_configuration.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_product(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, usage='revoke enrollment') 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 assert_correct_event_payload(self, instance, event_payload, order_number, currency, email, total, revenue, coupon, discount): """ Check that field values in the event payload correctly represent the completed order or refund. """ self.assertEqual([ 'coupon', 'currency', 'discount', 'email', 'orderId', 'products', 'revenue', 'total' ], sorted(event_payload.keys())) self.assertEqual(event_payload['orderId'], order_number) self.assertEqual(event_payload['currency'], currency) self.assertEqual(event_payload['coupon'], coupon) self.assertEqual(event_payload['discount'], discount) self.assertEqual(event_payload['email'], email) 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'], float(total)) self.assertEqual(event_payload['revenue'], float(revenue)) # value of revenue field should be the same as total. self.assertEqual(event_payload['revenue'], float(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(float(line.line_price_excl_tax), tracked_product['price']) self.assertEqual(line.quantity, tracked_product['quantity']) self.assertEqual(mode_for_product(line.product), tracked_product['sku']) self.assertEqual(line.product.get_product_class().name, tracked_product['category']) else: # Payload validation is currently limited to order and refund events self.fail()
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_product(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 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_product(seat), 'currency': stock_record.price_currency, 'price': float(stock_record.price_excl_tax), 'sku': stock_record.partner_sku, 'bulk_sku': bulk_sku, 'expires': self.get_seat_expiration(seat), }
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) # 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_product(seat) logger.info('Validating objects for [%s] mode...', mode) self.assert_stock_record_valid(seat.stockrecords.first(), seat, Decimal(self.prices[mode]))
def send_course_purchase_email(sender, order=None, **kwargs): # pylint: disable=unused-argument """Send course purchase notification email when a course is purchased.""" if waffle.switch_is_active('ENABLE_NOTIFICATIONS'): # We do not currently support email sending for orders with more than one item. if len(order.lines.all()) == ORDER_LINE_COUNT: product = order.lines.first().product credit_provider_id = getattr(product.attr, 'credit_provider', None) product_mode = mode_for_product(product) if not credit_provider_id: if product_mode != 'credit': # send course purchase email for verified courses send_notification(order.user, 'COURSE_PURCHASED', { 'course_title': product.title, }, order.site) else: logger.error( 'Failed to send credit receipt notification. Credit seat product [%s] has no provider.', product.id) return elif product.is_seat_product: provider_data = get_credit_provider_details( access_token=order.site.siteconfiguration.access_token, credit_provider_id=credit_provider_id, site_configuration=order.site.siteconfiguration) receipt_page_url = get_receipt_page_url( order_number=order.number, site_configuration=order.site.siteconfiguration) if provider_data: send_notification( order.user, 'CREDIT_RECEIPT', { 'course_title': product.title, 'receipt_page_url': receipt_page_url, 'credit_hours': product.attr.credit_hours, 'credit_provider': provider_data['display_name'], }, order.site) else: logger.info( 'Currently support receipt emails for order with one item.')
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_product(line.product), 'name': course.id if course else line.product.title, 'price': float(line.line_price_excl_tax), 'quantity': int(line.quantity), 'category': line.product.get_product_class().name, }
def track_completed_order(sender, order=None, **kwargs): # pylint: disable=unused-argument """ Emit a tracking event when 1. An order is placed OR 2. An enrollment code purchase order is placed. """ if order.total_excl_tax <= 0: return properties = { 'orderId': order.number, 'total': str(order.total_excl_tax), # For Rockerbox integration, we need a field named revenue since they cannot parse a field named total. # TODO: DE-1188: Remove / move Rockerbox integration code. 'revenue': str(order.total_excl_tax), 'currency': order.currency, 'discount': str(order.total_discount_incl_tax), '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_product(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() ], } if order.user: properties['email'] = order.user.email for line in order.lines.all(): if line.product.is_enrollment_code_product: # Send analytics events to track bulk enrollment code purchases. track_segment_event(order.site, order.user, 'Bulk Enrollment Codes Order Completed', properties) return if line.product.is_coupon_product: return voucher = order.basket_discounts.filter(voucher_id__isnull=False).first() coupon = voucher.voucher_code if voucher else None properties['coupon'] = coupon try: bundle_id = BasketAttribute.objects.get( basket=order.basket, attribute_type__name=BUNDLE).value_text program = get_program(bundle_id, order.basket.site.siteconfiguration) if len(order.lines.all()) < len(program.get('courses')): variant = 'partial' else: variant = 'full' bundle_product = { 'id': bundle_id, 'price': '0', 'quantity': str(len(order.lines.all())), 'category': 'bundle', 'variant': variant, 'name': program.get('title') } properties['products'].append(bundle_product) except BasketAttribute.DoesNotExist: logger.info( 'There is no program or bundle associated with order number %s', order.number) track_segment_event(order.site, order.user, 'Order Completed', properties)
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, request=request) basket_add_enterprise_catalog_attribute(basket, request.GET) basket.flush() basket.save() basket_addition = get_class('basket.signals', 'basket_addition') already_purchased_products = [] bundle = request.GET.get('bundle') _set_basket_bundle_status(bundle, basket) if request.site.siteconfiguration.enable_embargo_check: if not embargo_check(request.user, request.site, products): messages.error( request, _('Due to export controls, we cannot allow you to access this course at this time.' )) logger.warning( 'User [%s] blocked by embargo check, not adding products to basket', request.user.username) return basket is_multi_product_basket = len(products) > 1 for product in products: if product.is_enrollment_code_product or \ not UserAlreadyPlacedOrder.user_already_placed_order(user=request.user, product=product, site=request.site): 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, is_multi_product_basket=is_multi_product_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_product(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 or basket.vouchers.exists(): voucher = voucher or basket.vouchers.first() basket.clear_vouchers() is_valid, message = validate_voucher(voucher, request.user, basket, request.site) if is_valid: apply_voucher_on_basket_and_check_discount(voucher, request, basket) else: logger.warning( '[Code Redemption Failure] The voucher is not valid for this basket. ' 'User: %s, Basket: %s, Code: %s, Message: %s', request.user.username, request.basket.id, voucher.code, message) logger.info( '----------------------Baket Currency------------------------ %s', basket.currency) attribute_cookie_data(basket, request) return basket
def fulfill_product(self, order, lines): """ Fulfills the purchase of a 'Course Entitlement'. Uses the order and the lines to determine which courses to grant an entitlement for, and with certain certificate types. May result in an error if the Entitlement API cannot be reached, or if there is additional business logic errors when trying grant the entitlement. 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 grant an entitlement. lines (List of Lines): Order Lines, associated with purchased products in an Order. These should only be "Course Entitlement" products. Returns: The original set of lines, with new statuses set based on the success or failure of fulfillment. """ logger.info('Attempting to fulfill "Course Entitlement" product types for order [%s]', order.number) for line in lines: try: mode = mode_for_product(line.product) UUID = line.product.attr.UUID except AttributeError: logger.error('Entitlement Product does not have required attributes, [certificate_type, UUID]') line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR) continue data = { 'user': order.user.username, 'course_uuid': UUID, 'mode': mode, 'order_number': order.number, } try: entitlement_option = Option.objects.get(code='course_entitlement') entitlement_api_client = EdxRestApiClient( get_lms_entitlement_api_url(), jwt=order.site.siteconfiguration.access_token ) # POST to the Entitlement API. response = entitlement_api_client.entitlements.post(data) line.attributes.create(option=entitlement_option, value=response['uuid']) 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, UUID=UUID, mode=mode, user_id=order.user.id, ) except (Timeout, ConnectionError): logger.exception( '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 Exception: # pylint: disable=broad-except logger.exception( 'Unable to fulfill line [%d] of order [%s]', line.id, order.number ) line.set_status(LINE.FULFILLMENT_SERVER_ERROR) logger.info('Finished fulfilling "Course Entitlement" product types for order [%s]', order.number) return order, lines
def fulfill_product(self, order, lines, email_opt_in=False): """ Fulfills the purchase of a 'Course Entitlement'. Uses the order and the lines to determine which courses to grant an entitlement for, and with certain certificate types. May result in an error if the Entitlement API cannot be reached, or if there is additional business logic errors when trying grant the entitlement. Updates the user's email preferences based on email_opt_in as a side effect. 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 grant an entitlement. lines (List of Lines): Order Lines, associated with purchased products in an Order. These should only be "Course Entitlement" products. email_opt_in (bool): Whether the user should be opted in to emails as part of the fulfillment. Defaults to False. Returns: The original set of lines, with new statuses set based on the success or failure of fulfillment. """ logger.info( 'Attempting to fulfill "Course Entitlement" product types for order [%s]', order.number) for line in lines: try: mode = mode_for_product(line.product) UUID = line.product.attr.UUID except AttributeError: logger.error( 'Entitlement Product does not have required attributes, [certificate_type, UUID]' ) line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR) continue data = { 'user': order.user.username, 'course_uuid': UUID, 'mode': mode, 'order_number': order.number, 'email_opt_in': email_opt_in, } try: self._create_enterprise_customer_user(order) self.update_orderline_with_enterprise_discount_metadata( order, line) entitlement_option = Option.objects.get( code='course_entitlement') api_client = line.order.site.siteconfiguration.oauth_api_client entitlement_url = urljoin(get_lms_entitlement_api_url(), 'entitlements/') # POST to the Entitlement API. response = api_client.post(entitlement_url, json=data) response.raise_for_status() response = response.json() line.attributes.create(option=entitlement_option, value=response['uuid']) 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, UUID=UUID, mode=mode, user_id=order.user.id, ) except (Timeout, ReqConnectionError): logger.exception( 'Unable to fulfill line [%d] of order [%s] due to a network problem', line.id, order.number) order.notes.create( message= 'Fulfillment of order failed due to a network problem.', note_type='Error') line.set_status(LINE.FULFILLMENT_NETWORK_ERROR) except Exception: # pylint: disable=broad-except logger.exception('Unable to fulfill line [%d] of order [%s]', line.id, order.number) order.notes.create( message='Fulfillment of order failed due to an Exception.', note_type='Error') line.set_status(LINE.FULFILLMENT_SERVER_ERROR) logger.info( 'Finished fulfilling "Course Entitlement" product types for order [%s]', order.number) return order, lines
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_product(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: self._add_enterprise_data_to_enrollment_api_post(data, order) # Post to the Enrollment API. The LMS will take care of posting a new EnterpriseCourseEnrollment to # the Enterprise service if the user+course has a corresponding EnterpriseCustomerUser. 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( "Fulfillment of line [%d] on order [%s] failed with status code [%d]: %s", line.id, order.number, response.status_code, 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 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 = [] bundle = request.GET.get('bundle') if bundle: BasketAttribute.objects.update_or_create( basket=basket, attribute_type=BasketAttributeType.objects.get(name=BUNDLE), defaults={'value_text': bundle}) basket.clear_vouchers() else: BasketAttribute.objects.filter(basket=basket, attribute_type__name=BUNDLE).delete() if request.site.siteconfiguration.enable_embargo_check: if not embargo_check(request.user, request.site, products): messages.error( request, _('Due to export controls, we cannot allow you to access this course at this time.' )) logger.warning( 'User [%s] blocked by embargo check, not adding products to basket', request.user.username) return basket is_multi_product_basket = True if len(products) > 1 else False for product in products: if product.is_enrollment_code_product or \ not UserAlreadyPlacedOrder.user_already_placed_order(user=request.user, product=product, site=request.site): 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, is_multi_product_basket=is_multi_product_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_product(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 mock_program_detail_endpoint(self, program_uuid, discovery_api_url, empty=False, title='Test Program', include_entitlements=True, status='active'): """ Mocks the program detail endpoint on the Catalog API. Args: program_uuid (uuid): UUID of the mocked program. Returns: dict: Mocked program data. """ partner = PartnerFactory() data = None if not empty: courses = [] for i in range(1, 5): uuid = '268afbfc-cc1e-415b-a5d8-c58d955bcfc' + str(i) entitlement = create_or_update_course_entitlement( 'verified', 10, partner, uuid, uuid) entitlements = [] if include_entitlements: entitlements.append({ "mode": "verified", "price": "10.00", "currency": "USD", "sku": entitlement.stockrecords.first().partner_sku }) key = 'course-v1:test-org+course+' + str(i) course_runs = [] for __ in range(1, 4): course_run = CourseFactory(partner=self.partner) course_run.create_or_update_seat('audit', False, Decimal(0)) course_run.create_or_update_seat('verified', True, Decimal(100)) course_runs.append({ 'key': course_run.id, 'seats': [{ 'type': mode_for_product(seat), 'sku': seat.stockrecords.get( partner=self.partner).partner_sku, } for seat in course_run.seat_products] }) courses.append({ 'key': key, 'uuid': uuid, 'course_runs': course_runs, 'entitlements': entitlements, }) program_uuid = str(program_uuid) data = { 'uuid': program_uuid, 'title': title, 'status': status, '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=discovery_api_url.strip('/'), uuid=program_uuid), body=json.dumps(data), content_type='application/json') return data
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_add_enterprise_catalog_attribute(basket, request.GET) basket.flush() basket.save() basket_addition = get_class('basket.signals', 'basket_addition') already_purchased_products = [] bundle = request.GET.get('bundle') _set_basket_bundle_status(bundle, basket) if request.site.siteconfiguration.enable_embargo_check: if not embargo_check(request.user, request.site, products): messages.error( request, _('Due to export controls, we cannot allow you to access this course at this time.' )) logger.warning( 'User [%s] blocked by embargo check, not adding products to basket', request.user.username) return basket is_multi_product_basket = len(products) > 1 for product in products: # Multiple clicks can try adding twice, return if product is seat already in basket if is_duplicate_seat_attempt(basket, product): logger.info( 'User [%s] repeated request to add [%s] seat of course [%s], will ignore', request.user.username, mode_for_product(product), product.course_id) return basket if product.is_enrollment_code_product or \ not UserAlreadyPlacedOrder.user_already_placed_order(user=request.user, product=product, site=request.site): 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, is_multi_product_basket=is_multi_product_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_product(product), product.course_id) if already_purchased_products and basket.is_empty: raise AlreadyPlacedOrderException # Waiting to check and send segment event until after products are added into the basket # just in case the AlreadyPlacedOrderException is raised if bundle: program = get_program(bundle, request.site.siteconfiguration) bundle_properties = { 'bundle_id': bundle, 'cart_id': basket.id, 'title': program.get('title'), 'total_price': basket.total_excl_tax, 'quantity': basket.lines.count(), } if program.get('type_attrs', {}).get('slug') and program.get('marketing_slug'): bundle_properties['marketing_slug'] = program['type_attrs'][ 'slug'] + '/' + program.get('marketing_slug') track_segment_event(request.site, request.user, 'edx.bi.ecommerce.basket.bundle_added', bundle_properties) if len(products) == 1 and products[0].is_enrollment_code_product: basket.clear_vouchers() elif voucher or basket.vouchers.exists(): voucher = voucher or basket.vouchers.first() basket.clear_vouchers() is_valid, message = validate_voucher(voucher, request.user, basket, request.site) if is_valid: apply_voucher_on_basket_and_check_discount(voucher, request, basket) else: logger.warning( '[Code Redemption Failure] The voucher is not valid for this basket. ' 'User: %s, Basket: %s, Code: %s, Message: %s', request.user.username, request.basket.id, voucher.code, message) attribute_cookie_data(basket, request) return basket
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 site_configuration = order.site.siteconfiguration if not site_configuration.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 lines = order.lines.all() # We are not sending multi product orders to sailthru for now, because # the abandoned cart email does not yet support baskets with multiple products if len(lines) > 1: return for line in lines: # get product product = line.product sku = line.partner_sku # ignore everything except course seats. no support for coupons as of yet if product.is_seat_product: 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_product(product), unit_cost=price, course_id=course_id, currency=order.currency, site_code=site_configuration.partner.short_code, message_id=message_id, sku=sku)