def record_payment(self, basket, handled_processor_response): self.emit_checkout_step_events(basket, handled_processor_response, self.payment_processor) track_segment_event(basket.site, basket.owner, 'Payment Info Entered', {'checkout_id': basket.order_number}) source_type, __ = SourceType.objects.get_or_create( name=self.payment_processor.NAME) total = handled_processor_response.total reference = handled_processor_response.transaction_id source = Source(source_type=source_type, currency=handled_processor_response.currency, amount_allocated=total, amount_debited=total, reference=reference, label=handled_processor_response.card_number, card_type=handled_processor_response.card_type) event_type, __ = PaymentEventType.objects.get_or_create( name=PaymentEventTypeName.PAID) payment_event = PaymentEvent( event_type=event_type, amount=total, reference=reference, processor_name=self.payment_processor.NAME) self.add_payment_source(source) self.add_payment_event(payment_event) audit_log('payment_received', amount=payment_event.amount, basket_id=basket.id, currency=source.currency, processor_name=payment_event.processor_name, reference=payment_event.reference, user_id=basket.owner.id)
def _use_payment_microfrontend(request): """ Return whether the current request should use the payment MFE. """ if ( request.site.siteconfiguration.enable_microfrontend_for_basket_page and request.site.siteconfiguration.payment_microfrontend_url ): # Force the user into the MFE bucket for testing payment_mfe_bucket_forced = _force_payment_microfrontend_bucket(request) if payment_mfe_bucket_forced: bucket = PAYMENT_MFE_BUCKET else: # Bucket 50% of users to use the payment MFE for A/B testing. bucket = stable_bucketing_hash_group("payment-mfe", 2, request.user.username) payment_microfrontend_flag_enabled = waffle.flag_is_active( request, ENABLE_MICROFRONTEND_FOR_BASKET_PAGE_FLAG_NAME ) track_segment_event( request.site, request.user, 'edx.bi.experiment.user.bucketed', { 'bucket': bucket, 'experiment': 'payment-mfe', 'forcedIntoBucket': payment_mfe_bucket_forced, 'paymentMfeEnabled': payment_microfrontend_flag_enabled, }, ) return bucket == PAYMENT_MFE_BUCKET and payment_microfrontend_flag_enabled else: return False
def flush(self): """Remove all products in basket and fire Segment 'Product Removed' Analytic event for each""" for line in self.all_lines(): properties = translate_basket_line_for_segment(line) track_segment_event(self.site, self.owner, 'Product Removed', properties) super(Basket, self).flush() # pylint: disable=bad-super-call
def add_REV1074_information_to_url_if_eligible(redirect_url, request, sku): """ For https://openedx.atlassian.net/browse/REV-1074 we are testing a mostly hardcoded version of the checkout page. We are trying to improve performance and measure if there is an effect on revenue. Here we determine which users are eligible to be in the experiment, then bucket the users into a treatment and control group, and send a log message to record this information for our experiment analysis """ is_eligible_for_experiment = _is_eligible_for_REV1074_experiment(request, sku) bucket = stable_bucketing_hash_group('REV-1074', 2, request.user.username) route = bucket username = request.user.username basket = request.basket properties = { 'experiment': 'static_checkout_page', 'cart_id': basket.id } if not is_eligible_for_experiment: route = 0 logger.info('REV1074: Should be omitted from experiment results: user [%s] with basket [%s].', username, basket) properties['bucket'] = 'not_in_experiment' elif is_eligible_for_experiment and bucket: logger.info('REV1074: Bucketed into treatment variation: user [%s] with basket [%s].', username, basket) properties['bucket'] = 'treatment' else: logger.info('REV1074: Bucketed into control variation: user [%s] with basket [%s].', username, basket) properties['bucket'] = 'control' track_segment_event(request.site, request.user, 'edx.bi.experiment.user.bucketed', properties) if route: redirect_url += sku + '.html' return redirect_url
def get(self, request): # Send time when this view is called - https://openedx.atlassian.net/browse/REV-984 properties = {'emitted_at': time.time()} track_segment_event(request.site, request.user, 'Basket Add Items View Called', properties) try: skus = self._get_skus(request) products = self._get_products(request, skus) voucher = self._get_voucher(request) logger.info('Starting payment flow for user [%s] for products [%s].', request.user.username, skus) available_products = self._get_available_products(request, products) self._set_email_preference_on_basket(request) try: prepare_basket(request, available_products, voucher) except AlreadyPlacedOrderException: return render(request, 'edx/error.html', {'error': _('You have already purchased these products')}) return self._redirect_response_to_basket_or_payment(request) except BadRequestException as e: return HttpResponseBadRequest(six.text_type(e)) except RedirectException as e: return e.response
def handle_payment(self, response, basket): # pylint: disable=arguments-differ """ Handle any payment processing and record payment sources and events. This method is responsible for handling payment and recording the payment sources (using the add_payment_source method) and payment events (using add_payment_event) so they can be linked to the order when it is saved later on. """ properties = { 'basket_id': basket.id, 'processor_name': self.payment_processor.NAME, } # If payment didn't go through, the handle_processor_response function will raise an error. We want to # send the event regardless of if the payment didn't go through. try: handled_processor_response = self.payment_processor.handle_processor_response( response, basket=basket) except Exception as ex: properties.update({ 'success': False, 'payment_error': type(ex).__name__, }) raise else: # We only record successful payments in the database. self.record_payment(basket, handled_processor_response) properties.update({ 'total': handled_processor_response.total, 'success': True, }) finally: track_segment_event(basket.site, basket.owner, 'Payment Processor Response', properties)
def get(self, request, *args, **kwargs): basket = request.basket try: properties = { 'cart_id': basket.id, 'products': [ translate_basket_line_for_segment(line) for line in basket.all_lines() ], } track_segment_event(request.site, request.user, 'Cart Viewed', properties) properties = {'checkout_id': basket.order_number, 'step': 1} track_segment_event(request.site, request.user, 'Checkout Step Viewed', properties) except Exception: # pylint: disable=broad-except logger.exception( 'Failed to fire Cart Viewed event for basket [%d]', basket.id) if has_enterprise_offer(basket) and basket.total_incl_tax == Decimal( 0): return redirect('checkout:free-checkout') else: return super(BasketSummaryView, self).get(request, *args, **kwargs)
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 add_product(self, product, quantity=1, options=None): """ Add the indicated product to basket. Performs AbstractBasket add_product method and fires Google Analytics 'Product Added' event. """ line, created = super(Basket, self).add_product(product, quantity, options) # pylint: disable=bad-super-call properties = translate_basket_line_for_segment(line) properties['cart_id'] = self.id track_segment_event(self.site, self.owner, 'Product Added', properties) return line, created
def flush(self): """Remove all products in basket and fire Segment 'Product Removed' Analytic event for each""" for line in self.all_lines(): # Do not fire events for free items. The volume we see for edX.org leads to a dramatic increase in CPU # usage. Given that orders for free items are ignored, there is no need for these events. if line.stockrecord.price_excl_tax > 0: properties = translate_basket_line_for_segment(line) track_segment_event(self.site, self.owner, 'Product Removed', properties) super(Basket, self).flush() # pylint: disable=bad-super-call
def get(self, request): # Send time when this view is called - https://openedx.atlassian.net/browse/REV-984 properties = {'emitted_at': time.time()} track_segment_event(request.site, request.user, 'Basket Add Items View Called', properties) try: skus = self._get_skus(request) products = self._get_products(request, skus) voucher = None invalid_code = None code = request.GET.get('code', None) try: voucher = self._get_voucher(request) except Voucher.DoesNotExist as e: # pragma: nocover # Display an error message when an invalid code is passed as a parameter invalid_code = code logger.info( 'Starting payment flow for user [%s] for products [%s].', request.user.username, skus) available_products = self._get_available_products( request, products) try: basket = prepare_basket(request, available_products, voucher) except AlreadyPlacedOrderException: return render( request, 'edx/error.html', {'error': _('You have already purchased these products')}) self._set_email_preference_on_basket(request, basket) # Used basket object from request to allow enterprise offers # being applied on basket via BasketMiddleware self.verify_enterprise_needs(request.basket) if code and not request.basket.vouchers.exists(): if not (len(available_products) == 1 and available_products[0].is_enrollment_code_product): # Display an error message when an invalid code is passed as a parameter invalid_code = code return self._redirect_response_to_basket_or_payment( request, invalid_code) except BadRequestException as e: return HttpResponseBadRequest(str(e)) except RedirectException as e: return e.response
def flush(self): """Remove all products in basket and fire Segment 'Product Removed' Analytic event for each""" cached_response = RequestCache.get_cached_response(TEMPORARY_BASKET_CACHE_KEY) if cached_response.is_hit: # Do not track anything. This is a temporary basket calculation. return for line in self.all_lines(): # Do not fire events for free items. The volume we see for edX.org leads to a dramatic increase in CPU # usage. Given that orders for free items are ignored, there is no need for these events. if line.stockrecord.price_excl_tax > 0: properties = translate_basket_line_for_segment(line) track_segment_event(self.site, self.owner, 'Product Removed', properties) # Call flush after we fetch all_lines() which is cleared during flush() super(Basket, self).flush() # pylint: disable=bad-super-call
def test_track_segment_event(self): """ The function should fire an event to Segment if the site is properly configured. """ self.site_configuration.segment_key = 'fake-key' self.site_configuration.save() user, event, properties = self._get_generic_segment_event_parameters() user_tracking_id, ga_client_id, lms_ip = parse_tracking_context(user) context = { 'ip': lms_ip, 'Google Analytics': { 'clientId': ga_client_id } } with mock.patch.object(Client, 'track') as mock_track: track_segment_event(self.site, user, event, properties) mock_track.assert_called_once_with(user_tracking_id, event, properties, context=context)
def fire_segment_events(self, request, basket): try: properties = { 'cart_id': basket.id, 'products': [translate_basket_line_for_segment(line) for line in basket.all_lines()], } track_segment_event(request.site, request.user, 'Cart Viewed', properties) properties = { 'checkout_id': basket.order_number, 'step': 1 } track_segment_event(request.site, request.user, 'Checkout Step Viewed', properties) except Exception: # pylint: disable=broad-except logger.exception('Failed to fire Cart Viewed event for basket [%d]', basket.id)
def track_completed_refund(sender, refund=None, **kwargs): # pylint: disable=unused-argument """Emit a tracking event when a refund is completed.""" if refund.total_credit_excl_tax <= 0: return properties = { 'orderId': refund.order.number, 'products': [{ 'id': line.order_line.partner_sku, 'quantity': line.quantity, } for line in refund.lines.all()], } track_segment_event(refund.order.site, refund.user, 'Order Refunded', properties)
def add_product(self, product, quantity=1, options=None): """ Add the indicated product to basket. Performs AbstractBasket add_product method and fires Google Analytics 'Product Added' event. """ line, created = super(Basket, self).add_product(product, quantity, options) # pylint: disable=bad-super-call # Do not fire events for free items. The volume we see for edX.org leads to a dramatic increase in CPU # usage. Given that orders for free items are ignored, there is no need for these events. if line.stockrecord.price_excl_tax > 0: properties = translate_basket_line_for_segment(line) properties['cart_id'] = self.id track_segment_event(self.site, self.owner, 'Product Added', properties) return line, created
def flush(self): """Remove all products in basket and fire Segment 'Product Removed' Analytic event for each""" cached_response = DEFAULT_REQUEST_CACHE.get_cached_response( TEMPORARY_BASKET_CACHE_KEY) if cached_response.is_found: # Do not track anything. This is a temporary basket calculation. return product_removed_event_fired = False for line in self.all_lines(): # Do not fire events for free items. The volume we see for edX.org leads to a dramatic increase in CPU # usage. Given that orders for free items are ignored, there is no need for these events. if line.stockrecord.price_excl_tax > 0: properties = translate_basket_line_for_segment(line) track_segment_event(self.site, self.owner, 'Product Removed', properties) product_removed_event_fired = True # Validate we sent an event for > 0 products to check if the bundle event is even necessary if product_removed_event_fired: try: bundle_id = BasketAttribute.objects.get( basket=self, attribute_type__name=BUNDLE).value_text program = get_program(bundle_id, self.site.siteconfiguration) bundle_properties = { 'bundle_id': bundle_id, 'title': program.get('title'), 'total_price': self.total_excl_tax, 'quantity': self.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(self.site, self.owner, 'edx.bi.ecommerce.basket.bundle_removed', bundle_properties) except BasketAttribute.DoesNotExist: # Nothing to do here. It's not a bundle ¯\_(ツ)_/¯ pass # Call flush after we fetch all_lines() which is cleared during flush() super(Basket, self).flush() # pylint: disable=bad-super-call
def get(self, request, *args, **kwargs): basket = request.basket try: properties = { 'cart_id': basket.id, 'products': [ translate_basket_line_for_segment(line) for line in basket.all_lines() ], } track_segment_event(request.site, request.user, 'Cart Viewed', properties) except Exception: # pylint: disable=broad-except logger.exception( 'Failed to fire Cart Viewed event for basket [%d]', basket.id) return super(BasketSummaryView, self).get(request, *args, **kwargs)
def test_track_segment_event_without_segment_key(self): """ If the site has no Segment key, the function should log a debug message and NOT send an event.""" self.site_configuration.segment_key = None self.site_configuration.save() with mock.patch('logging.Logger.debug') as mock_debug: msg = 'Event [foo] was NOT fired because no Segment key is set for site configuration [{}]' msg = msg.format(self.site_configuration.pk) self.assertEqual(track_segment_event(self.site, self.create_user(), 'foo', {}), (False, msg)) mock_debug.assert_called_with(msg)
def add_product(self, product, quantity=1, options=None): """ Add the indicated product to basket. Performs AbstractBasket add_product method and fires Google Analytics 'Product Added' event. """ line, created = super(Basket, self).add_product(product, quantity, options) # pylint: disable=bad-super-call cached_response = DEFAULT_REQUEST_CACHE.get_cached_response(TEMPORARY_BASKET_CACHE_KEY) if cached_response.is_found: # Do not track anything. This is a temporary basket calculation. return line, created # Do not fire events for free items. The volume we see for edX.org leads to a dramatic increase in CPU # usage. Given that orders for free items are ignored, there is no need for these events. if line.stockrecord.price_excl_tax > 0: properties = translate_basket_line_for_segment(line) properties['cart_id'] = self.id track_segment_event(self.site, self.owner, 'Product Added', properties) return line, created
def get(self, request, *args, **kwargs): basket = request.basket try: properties = { 'cart_id': basket.id, 'products': [ translate_basket_line_for_segment(line) for line in basket.all_lines() ], } track_segment_event(request.site, request.user, 'Cart Viewed', properties) properties = {'checkout_id': basket.order_number, 'step': 1} track_segment_event(request.site, request.user, 'Checkout Step Viewed', properties) except Exception: # pylint: disable=broad-except logger.exception( 'Failed to fire Cart Viewed event for basket [%d]', basket.id) if has_enterprise_offer(basket) and basket.total_incl_tax == Decimal( 0): return redirect('checkout:free-checkout') else: # lumsx is giving a thirdparty method for payment rather than a gateway so had to make a minimal # processor and integerate the API, if client side processor matches with the site configurations # than move forward towards API configuration_helpers = request.site.siteconfiguration.edly_client_theme_branding_settings custom_processor_name = configuration_helpers.get( 'PAYMENT_PROCESSOR_NAME') if custom_processor_name == self.request.site.siteconfiguration.client_side_payment_processor: # return LumsxpayExecutionView.get_voucher_api(request) return redirect_to_referrer(self.request, 'lumsxpay:execute') return super(BasketSummaryView, self).get(request, *args, **kwargs)
def test_track_segment_event(self): """ The function should fire an event to Segment if the site is properly configured. """ properties = {'key': 'value'} self.site_configuration.segment_key = 'fake-key' self.site_configuration.save() user = self.create_user( tracking_context={ 'ga_client_id': 'test-client-id', 'lms_user_id': 'foo', 'lms_ip': '18.0.0.1', } ) user_tracking_id, ga_client_id, lms_ip = parse_tracking_context(user) context = { 'ip': lms_ip, 'Google Analytics': { 'clientId': ga_client_id } } event = 'foo' with mock.patch.object(Client, 'track') as mock_track: track_segment_event(self.site, user, event, properties) mock_track.assert_called_once_with(user_tracking_id, event, properties, context=context)
def emit_checkout_step_events(self, basket, handled_processor_response, payment_processor): """ Emit events necessary to track the user in the checkout funnel. """ properties = { 'checkout_id': basket.order_number, 'step': 1, 'payment_method': '{} | {}'.format(handled_processor_response.card_type, payment_processor.NAME) } track_segment_event(basket.site, basket.owner, 'Checkout Step Completed', properties) properties['step'] = 2 track_segment_event(basket.site, basket.owner, 'Checkout Step Viewed', properties) track_segment_event(basket.site, basket.owner, 'Checkout Step Completed', properties)
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) 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