Example #1
0
    def test_get_program(self):
        """
        The method should return data from the Discovery Service API.
        Data should be cached for subsequent calls.
        """
        data = self.mock_program_detail_endpoint(self.program_uuid,
                                                 self.discovery_api_url)
        self.assertEqual(
            get_program(self.program_uuid, self.site.siteconfiguration), data)

        # The program data should be cached
        httpretty.disable()
        self.assertEqual(
            get_program(self.program_uuid, self.site.siteconfiguration), data)
Example #2
0
 def get_context_data(self, **kwargs):
     context = super(ProgramOfferUpdateView, self).get_context_data(**kwargs)
     context.update({
         'editing': True,
         'program': get_program(self.object.condition.program_uuid,
                                self.request.site.siteconfiguration),
     })
     return context
Example #3
0
    def get_context_data(self, **kwargs):
        context = super(ProgramOfferListView, self).get_context_data(**kwargs)

        # TODO: In the future, we should optimize our API calls, pulling the program data in as few calls as possible.
        offers = []
        for offer in context['object_list']:
            offer.program = get_program(offer.condition.program_uuid, self.request.site.siteconfiguration)
            offers.append(offer)

        return context
Example #4
0
    def _get_cart_viewed_event_properties(self, basket, data):
        """
        Gets the event properties for the cart viewed event.
        """
        # First we need to check if the basket is a bundle
        try:
            bundle_id = BasketAttribute.objects.get(
                basket=basket, attribute_type__name=BUNDLE).value_text
        except BasketAttribute.DoesNotExist:
            # No reason to raise an error. Just means it's a single product and not a bundle
            bundle_id = None

        product_slug = None
        # Now we can set fields based on if our basket contains a bundle or not
        if bundle_id:
            program = get_program(bundle_id, basket.site.siteconfiguration)
            if len(basket.all_lines()) < len(program.get('courses')):
                bundle_variant = 'partial_bundle'
            else:
                bundle_variant = 'full_bundle'
            product_subject = program.get('subjects') and program.get(
                'subjects')[0].get('slug')
            if program.get('type_attrs',
                           {}).get('slug') and program.get('marketing_slug'):
                product_slug = program['type_attrs'][
                    'slug'] + '/' + program.get('marketing_slug')
            product_title = program.get('title')
        else:
            bundle_variant = None
            product_subject = data['products'][0]['subject']
            product_title = data['products'][0]['title']

        return {
            'basket_discount':
            data['summary_discounts'],
            'basket_original_price':
            data['summary_price'],
            'basket_total':
            data['order_total'],
            'bundle_variant':
            bundle_variant,
            'currency':
            basket.currency,
            'products': [{
                'title': product['title'],
                'image': product['image_url']
            } for product in data['products']],
            'product_slug':
            product_slug,
            'product_subject':
            product_subject,
            'product_title':
            product_title,
        }
Example #5
0
 def test_get_program_not_found(self):  # pylint: disable=unused-argument
     """
     The method should log not found errors for program data
     """
     self.mock_program_detail_endpoint(self.program_uuid,
                                       self.discovery_api_url,
                                       empty=True)
     with mock.patch.object(ProgramsApiClient,
                            'get_program',
                            side_effect=HttpNotFoundError):
         with LogCapture(LOGGER_NAME) as logger:
             response = get_program(self.program_uuid,
                                    self.site.siteconfiguration)
             self.assertIsNone(response)
             msg = 'No program data found for {}'.format(self.program_uuid)
             logger.check((LOGGER_NAME, 'DEBUG', msg))
Example #6
0
    def _get_applicable_skus(self, site_configuration):
        """ SKUs to which this condition applies. """
        program_skus = set()
        program = get_program(self.program_uuid, site_configuration)
        if program:
            applicable_seat_types = program['applicable_seat_types']

            for course in program['courses']:
                for course_run in course['course_runs']:
                    program_skus.update(
                        {seat['sku'] for seat in course_run['seats'] if seat['type'] in applicable_seat_types}
                    )
                for entitlement in course['entitlements']:
                    if entitlement['mode'].lower() in applicable_seat_types:
                        program_skus.add(entitlement['sku'])
        return program_skus
Example #7
0
    def get_applicable_skus(self, site_configuration):
        """ SKUs to which this condition applies. """
        program_course_run_skus = set()
        program = get_program(self.program_uuid, site_configuration)
        if program:
            applicable_seat_types = program['applicable_seat_types']

            for course in program['courses']:
                for course_run in course['course_runs']:
                    program_course_run_skus.update(
                        set([
                            seat['sku'] for seat in course_run['seats']
                            if seat['type'] in applicable_seat_types
                        ]))

        return program_course_run_skus
Example #8
0
 def test_get_program_failure(self, exc):  # pylint: disable=unused-argument
     """
     The method should log errors in retrieving program data
     """
     self.mock_program_detail_endpoint(self.program_uuid,
                                       self.discovery_api_url,
                                       empty=True)
     with mock.patch.object(ProgramsApiClient,
                            'get_program',
                            side_effect=exc):
         with LogCapture(LOGGER_NAME) as logger:
             response = get_program(self.program_uuid,
                                    self.site.siteconfiguration)
             self.assertIsNone(response)
             msg = 'Failed to retrieve program details for {}'.format(
                 self.program_uuid)
             logger.check((LOGGER_NAME, 'DEBUG', msg))
Example #9
0
    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
Example #10
0
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)
Example #11
0
    def is_satisfied(self, offer, basket):  # pylint: disable=unused-argument
        """
        Determines if a user is eligible for a program offer based on products in their basket
        and their existing course enrollments.

        Args:
            basket : contains information on line items for order, associated siteconfiguration
                        for retrieving program details, and associated user for retrieving enrollments
        Returns:
            bool
        """
        basket_skus = set(
            [line.stockrecord.partner_sku for line in basket.all_lines()])
        try:
            program = get_program(self.program_uuid,
                                  basket.site.siteconfiguration)
        except (HttpNotFoundError, SlumberBaseException, Timeout):
            return False

        if program:
            applicable_seat_types = program['applicable_seat_types']
        else:
            return False

        retrieve_entitlements = self._has_entitlements(program)
        enrollments, entitlements = self._get_user_ownership_data(
            basket, retrieve_entitlements)

        for course in program['courses']:
            # If the user is already enrolled in a course, we do not need to check their basket for it
            if any(enrollment['course_details']['course_id'] in
                   [run['key'] for run in course['course_runs']]
                   and enrollment['mode'] in applicable_seat_types
                   for enrollment in enrollments):
                continue
            if any(course['uuid'] == entitlement['course_uuid']
                   and entitlement['mode'] in applicable_seat_types
                   for entitlement in entitlements):
                continue

            # If the  basket has no SKUs left, but we still have courses over which
            # to iterate, the user cannot meet the condition that all courses be represented.
            if not basket_skus:
                return False

            # Get all of the SKUs that can satisfy this course
            skus = set()
            for course_run in course['course_runs']:
                skus.update(
                    set([
                        seat['sku'] for seat in course_run['seats']
                        if seat['type'] in applicable_seat_types
                    ]))
            for entitlement in course['entitlements']:
                if entitlement['mode'].lower() in applicable_seat_types:
                    skus.add(entitlement['sku'])

            # The lack of a difference in the set of SKUs in the basket and the course indicates that
            # that there is no intersection. Therefore, the basket contains no SKUs for the current course.
            # Because the user is also not enrolled in the course, it follows that the program condition is not met.
            diff = basket_skus.difference(skus)
            if diff == basket_skus:
                return False

            # If there is a difference between the basket SKUs and course SKUs, it represents the basket SKUs
            # minus the SKUs for the current course. Since we have already verified the course is represented,
            # its SKUs can be safely removed from the set of SKUs in the basket being checked. Note that this
            # does NOT affect the actual basket, just our copy of its SKUs.
            basket_skus = diff

        return True
Example #12
0
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
Example #13
0
    def is_satisfied(self, offer, basket):  # pylint: disable=unused-argument
        """
        Determines if a user is eligible for a program offer based on products in their basket
        and their existing course enrollments.

        Args:
            basket : contains information on line items for order, associated siteconfiguration
                        for retrieving program details, and associated user for retrieving enrollments
        Returns:
            bool
        """

        basket_skus = set(
            [line.stockrecord.partner_sku for line in basket.all_lines()])
        try:
            program = get_program(self.program_uuid,
                                  basket.site.siteconfiguration)
        except (HttpNotFoundError, SlumberBaseException, Timeout):
            return False

        if program:
            applicable_seat_types = program['applicable_seat_types']
        else:
            return False

        enrollments = []
        if basket.site.siteconfiguration.enable_partial_program:
            api_resource = 'enrollments'
            cache_key = get_cache_key(
                site_domain=basket.site.domain,
                resource=api_resource,
                username=basket.owner.username,
            )
            enrollments = cache.get(cache_key, [])
            if not enrollments:
                api = basket.site.siteconfiguration.enrollment_api_client
                user = basket.owner.username
                try:
                    enrollments = api.enrollment.get(user=user)
                    cache.set(cache_key, enrollments,
                              settings.ENROLLMENT_API_CACHE_TIMEOUT)
                except (ConnectionError, SlumberBaseException, Timeout) as exc:
                    logger.error('Failed to retrieve enrollments: %s',
                                 str(exc))

        for course in program['courses']:
            # If the user is already enrolled in a course, we do not need to check their basket for it
            if any(course['key'] in enrollment['course_details']['course_id']
                   and enrollment['mode'] in applicable_seat_types
                   for enrollment in enrollments):
                continue

            # If the  basket has no SKUs left, but we still have courses over which
            # to iterate, the user cannot meet the condition that all courses be represented.
            if not basket_skus:
                return False

            # Get all of the SKUs that can satisfy this course
            skus = set()
            for course_run in course['course_runs']:
                skus.update(
                    set([
                        seat['sku'] for seat in course_run['seats']
                        if seat['type'] in applicable_seat_types
                    ]))

            # The lack of a difference in the set of SKUs in the basket and the course indicates that
            # that there is no intersection. Therefore, the basket contains no SKUs for the current course.
            # Because the user is also not enrolled in the course, it follows that the program condition is not met.
            diff = basket_skus.difference(skus)
            if diff == basket_skus:
                return False

            # If there is a difference between the basket SKUs and course SKUs, it represents the basket SKUs
            # minus the SKUs for the current course. Since we have already verified the course is represented,
            # its SKUs can be safely removed from the set of SKUs in the basket being checked. Note that this
            # does NOT affect the actual basket, just our copy of its SKUs.
            basket_skus = diff

        return True