Exemple #1
0
 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)
Exemple #2
0
    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()
Exemple #3
0
 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)
Exemple #4
0
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'))
Exemple #5
0
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'))
Exemple #6
0
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
            }
        },
    )
Exemple #7
0
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)
Exemple #8
0
    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]))
Exemple #9
0
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'))
Exemple #10
0
    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]))
Exemple #11
0
 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)
Exemple #12
0
    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)
Exemple #13
0
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)
Exemple #14
0
    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)
Exemple #15
0
 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),
     }
Exemple #16
0
 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)
Exemple #18
0
    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)
Exemple #19
0
    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()
Exemple #20
0
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)
Exemple #21
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.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
Exemple #22
0
    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)
Exemple #24
0
    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
Exemple #25
0
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)
Exemple #26
0
    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
Exemple #27
0
 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),
     }
Exemple #28
0
 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),
     }
Exemple #29
0
    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)
Exemple #30
0
    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
Exemple #31
0
    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])
Exemple #33
0
    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),
        }
Exemple #34
0
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
            }
        },
    )
Exemple #35
0
    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])
Exemple #36
0
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
            }
        },
    )
Exemple #37
0
    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
Exemple #38
0
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,
    }
Exemple #39
0
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]))
Exemple #42
0
    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
Exemple #43
0
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]))
Exemple #45
0
 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 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]))
Exemple #47
0
    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
Exemple #48
0
    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)
Exemple #49
0
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)
Exemple #50
0
    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)
Exemple #51
0
    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
Exemple #52
0
 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)