Exemple #1
0
 def test_mode_for_product(self, certificate_type, id_verification_required, mode):
     """ Verify the correct enrollment mode is returned for a given seat. """
     course = CourseFactory(id='edx/Demo_Course/DemoX', partner=self.partner)
     seat = course.create_or_update_seat(certificate_type, id_verification_required, 10.00)
     self.assertEqual(mode_for_product(seat), mode)
     enrollment_code = course.enrollment_code_product
     if enrollment_code:  # We should only have enrollment codes for allowed types
         self.assertEqual(mode_for_product(enrollment_code), mode)
Exemple #2
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 logger:
            EnrollmentFulfillmentModule().fulfill_product(
                self.order, list(self.order.lines.all()))

            line = self.order.lines.get()
            logger.check_present((
                LOGGER_NAME, 'INFO',
                'line_fulfilled: course_id="{}", credit_provider="{}", mode="{}", order_line_id="{}", '
                'order_number="{}", product_class="{}", user_id="{}"'.format(
                    line.product.attr.course_key,
                    line.product.attr.credit_provider,
                    mode_for_product(line.product),
                    line.id,
                    line.order.number,
                    line.product.get_product_class().name,
                    line.order.user.id,
                )))

        self.assertEqual(LINE.COMPLETE, line.status)

        actual = json.loads(httpretty.last_request().body.decode('utf-8'))
        expected = {
            'user':
            self.order.user.username,
            'is_active':
            True,
            'mode':
            self.certificate_type,
            'course_details': {
                'course_id': self.course_id,
            },
            'enrollment_attributes': [{
                'namespace': 'order',
                'name': 'order_number',
                'value': self.order.number
            }, {
                'namespace':
                'order',
                'name':
                'date_placed',
                'value':
                self.order.date_placed.strftime(ISO_8601_FORMAT)
            }, {
                'namespace': 'credit',
                'name': 'provider_id',
                'value': self.provider
            }]
        }
        self.assertEqual(actual, expected)
Exemple #3
0
    def mock_program_detail_endpoint(self,
                                     program_uuid,
                                     discovery_api_url,
                                     empty=False,
                                     title='Test Program'):
        """ Mocks the program detail endpoint on the Catalog API.
        Args:
            program_uuid (uuid): UUID of the mocked program.

        Returns:
            dict: Mocked program data.
        """
        data = None
        if not empty:
            courses = []
            for i in range(1, 5):
                key = 'course-v1:test-org+course+' + str(i)
                course_runs = []
                for __ in range(1, 4):
                    course_run = CourseFactory()
                    course_run.create_or_update_seat('audit', False,
                                                     Decimal(0), self.partner)
                    course_run.create_or_update_seat('verified', True,
                                                     Decimal(100),
                                                     self.partner)

                    course_runs.append({
                        'key':
                        course_run.id,
                        'seats': [{
                            'type':
                            mode_for_product(seat),
                            'sku':
                            seat.stockrecords.get(
                                partner=self.partner).partner_sku,
                        } for seat in course_run.seat_products]
                    })

                courses.append({
                    'key': key,
                    'course_runs': course_runs,
                })

            program_uuid = str(program_uuid)
            data = {
                'uuid': program_uuid,
                'title': title,
                'type': 'MicroMockers',
                'courses': courses,
                'applicable_seat_types':
                ['verified', 'professional', 'credit'],
            }
        self.mock_access_token_response()
        httpretty.register_uri(method=httpretty.GET,
                               uri='{base}/programs/{uuid}/'.format(
                                   base=discovery_api_url.strip('/'),
                                   uuid=program_uuid),
                               body=json.dumps(data),
                               content_type='application/json')
        return data
Exemple #4
0
    def test_entitlement_module_fulfill(self):
        """ Test to ensure we can properly fulfill course entitlements. """

        self.mock_access_token_response()
        httpretty.register_uri(httpretty.POST,
                               get_lms_entitlement_api_url() + 'entitlements/',
                               status=200,
                               body=json.dumps(self.return_data),
                               content_type='application/json')

        # Attempt to fulfill entitlement.
        with LogCapture(LOGGER_NAME) as l:
            CourseEntitlementFulfillmentModule().fulfill_product(
                self.order, list(self.order.lines.all()))

            line = self.order.lines.get()
            l.check(
                (LOGGER_NAME, 'INFO',
                 'line_fulfilled: UUID="{}", mode="{}", order_line_id="{}", '
                 'order_number="{}", product_class="{}", user_id="{}"'.format(
                     line.product.attr.UUID,
                     mode_for_product(line.product),
                     line.id,
                     line.order.number,
                     line.product.get_product_class().name,
                     line.order.user.id,
                 )))

            course_entitlement_uuid = line.attributes.get(
                option=self.entitlement_option).value
            self.assertEqual(course_entitlement_uuid, '111-222-333')
            self.assertEqual(LINE.COMPLETE, line.status)
Exemple #5
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_product(seat)
            self.assert_stock_record_valid(seat.stockrecords.first(), seat, Decimal(self.prices[mode]))
    def _generate_event_properties(self,
                                   order,
                                   voucher=None,
                                   bundle_id=None,
                                   fullBundle=False):
        coupon = voucher.code if voucher else None
        properties = {
            'orderId':
            order.number,
            'total':
            float(order.total_excl_tax),
            'revenue':
            float(order.total_excl_tax),
            'currency':
            order.currency,
            'coupon':
            coupon,
            'discount':
            float(order.total_discount_incl_tax),
            'products': [{
                'id':
                line.partner_sku,
                'sku':
                mode_for_product(line.product),
                'name':
                line.product.course.id
                if line.product.course else line.product.title,
                'price':
                float(line.line_price_excl_tax),
                'quantity':
                line.quantity,
                'category':
                line.product.get_product_class().name,
                'title':
                line.product.title,
            } for line in order.lines.all()],
        }
        if order.user:
            properties['email'] = order.user.email
        if bundle_id:
            program = self.mock_get_program_data(fullBundle)
            if len(order.lines.all()) < len(program['courses']):
                variant = 'partial'
            else:
                variant = 'full'

            bundle_product = {
                'id': bundle_id,
                'price': 0,
                'quantity': len(order.lines.all()),
                'category': 'bundle',
                'variant': variant,
                'name': program['title']
            }
            properties['products'].append(bundle_product)

        return properties
Exemple #7
0
    def test_enrollment_module_fulfill(self):
        """Happy path test to ensure we can properly fulfill enrollments."""
        httpretty.register_uri(httpretty.POST,
                               get_lms_enrollment_api_url(),
                               status=200,
                               body='{}',
                               content_type=JSON)
        # Attempt to enroll.
        with LogCapture(LOGGER_NAME) as l:
            EnrollmentFulfillmentModule().fulfill_product(
                self.order, list(self.order.lines.all()))

            line = self.order.lines.get()
            l.check((
                LOGGER_NAME, 'INFO',
                'line_fulfilled: course_id="{}", credit_provider="{}", mode="{}", order_line_id="{}", '
                'order_number="{}", product_class="{}", user_id="{}"'.format(
                    line.product.attr.course_key,
                    None,
                    mode_for_product(line.product),
                    line.id,
                    line.order.number,
                    line.product.get_product_class().name,
                    line.order.user.id,
                )))

        self.assertEqual(LINE.COMPLETE, line.status)

        last_request = httpretty.last_request()
        actual_body = json.loads(last_request.body)
        actual_headers = last_request.headers

        expected_body = {
            'user':
            self.order.user.username,
            'is_active':
            True,
            'mode':
            self.certificate_type,
            'course_details': {
                'course_id': self.course_id,
            },
            'enrollment_attributes': [{
                'namespace': 'order',
                'name': 'order_number',
                'value': self.order.number
            }]
        }

        expected_headers = {
            'X-Edx-Ga-Client-Id': self.user.tracking_context['ga_client_id'],
            'X-Forwarded-For': self.user.tracking_context['lms_ip'],
        }

        self.assertDictContainsSubset(expected_headers, actual_headers)
        self.assertEqual(expected_body, actual_body)
Exemple #8
0
def process_basket_addition(sender,
                            product=None,
                            user=None,
                            request=None,
                            basket=None,
                            is_multi_product_basket=None,
                            **kwargs):  # pylint: disable=unused-argument
    """Tell Sailthru when payment started.

    Arguments:
            Parameters described at http://django-oscar.readthedocs.io/en/releases-1.1/ref/signals.html
    """

    if not waffle.switch_is_active('sailthru_enable'):
        return

    site_configuration = request.site.siteconfiguration
    if not site_configuration.enable_sailthru:
        return

    # ignore everything except course seats.  no support for coupons as of yet
    if product.is_seat_product:
        course_id = product.course_id
        stock_record = product.stockrecords.first()
        if stock_record:
            price = stock_record.price_excl_tax
            currency = stock_record.price_currency

        # save Sailthru campaign ID, if there is one
        message_id = request.COOKIES.get('sailthru_bid')
        if message_id and basket:
            BasketAttribute.objects.update_or_create(
                basket=basket,
                attribute_type=get_basket_attribute_type(),
                defaults={'value_text': message_id})

        # inform sailthru if there is a price.  The purpose of this call is to tell Sailthru when
        # an item has been added to the shopping cart so that an abandoned cart message can be sent
        # later if the purchase is not completed.  Abandoned cart support is only for purchases, not
        # for free enrolls
        if price and not is_multi_product_basket:
            update_course_enrollment.delay(
                user.email,
                _build_course_url(course_id),
                True,
                mode_for_product(product),
                unit_cost=price,
                course_id=course_id,
                currency=currency,
                site_code=site_configuration.partner.short_code,
                message_id=message_id)
Exemple #9
0
    def revoke_line(self, line):
        try:
            logger.info('Attempting to revoke fulfillment of Line [%d]...',
                        line.id)

            mode = mode_for_product(line.product)
            course_key = line.product.attr.course_key
            data = {
                'user': line.order.user.username,
                'is_active': False,
                'mode': mode,
                'course_details': {
                    'course_id': course_key,
                },
            }

            response = self._post_to_enrollment_api(data,
                                                    user=line.order.user,
                                                    usage='revoke enrollment')

            if response.status_code == status.HTTP_200_OK:
                audit_log('line_revoked',
                          order_line_id=line.id,
                          order_number=line.order.number,
                          product_class=line.product.get_product_class().name,
                          course_id=course_key,
                          certificate_type=getattr(line.product.attr,
                                                   'certificate_type', ''),
                          user_id=line.order.user.id)

                return True
            else:
                # check if the error / message are something we can recover from.
                data = response.json()
                detail = data.get('message', '(No details provided.)')
                if response.status_code == 400 and "Enrollment mode mismatch" in detail:
                    # The user is currently enrolled in different mode than the one
                    # we are refunding an order for.  Don't revoke that enrollment.
                    logger.info('Skipping revocation for line [%d]: %s',
                                line.id, detail)
                    return True
                else:
                    logger.error(
                        'Failed to revoke fulfillment of Line [%d]: %s',
                        line.id, detail)
        except Exception:  # pylint: disable=broad-except
            logger.exception('Failed to revoke fulfillment of Line [%d].',
                             line.id)

        return False
Exemple #10
0
    def assert_correct_event_payload(self, instance, event_payload,
                                     order_number, currency, email, total,
                                     revenue, coupon, discount):
        """
        Check that field values in the event payload correctly represent the
        completed order or refund.
        """
        self.assertEqual([
            'coupon', 'currency', 'discount', 'email', 'orderId', 'products',
            'revenue', 'total'
        ], sorted(event_payload.keys()))
        self.assertEqual(event_payload['orderId'], order_number)
        self.assertEqual(event_payload['currency'], currency)
        self.assertEqual(event_payload['coupon'], coupon)
        self.assertEqual(event_payload['discount'], discount)
        self.assertEqual(event_payload['email'], email)

        lines = instance.lines.all()
        self.assertEqual(len(lines), len(event_payload['products']))

        model_name = instance.__class__.__name__
        tracked_products_dict = {
            product['id']: product
            for product in event_payload['products']
        }

        if model_name == 'Order':
            self.assertEqual(event_payload['total'], float(total))
            self.assertEqual(event_payload['revenue'], float(revenue))
            # value of revenue field should be the same as total.
            self.assertEqual(event_payload['revenue'], float(total))

            for line in lines:
                tracked_product = tracked_products_dict.get(line.partner_sku)
                self.assertIsNotNone(tracked_product)
                self.assertEqual(line.product.course.id,
                                 tracked_product['name'])
                self.assertEqual(float(line.line_price_excl_tax),
                                 tracked_product['price'])
                self.assertEqual(line.quantity, tracked_product['quantity'])
                self.assertEqual(mode_for_product(line.product),
                                 tracked_product['sku'])
                self.assertEqual(line.product.get_product_class().name,
                                 tracked_product['category'])
        else:
            # Payload validation is currently limited to order and refund events
            self.fail()
Exemple #11
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_product(seat)
            logger.info('Validating objects for [%s] mode...', mode)

            stock_record = self.partner.stockrecords.get(product=seat)
            self.assert_seat_valid(seat, mode)
            self.assert_stock_record_valid(stock_record, seat, self.prices[mode])
Exemple #12
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_product(seat),
            'currency': stock_record.price_currency,
            'price': float(stock_record.price_excl_tax),
            'sku': stock_record.partner_sku,
            'bulk_sku': bulk_sku,
            'expires': self.get_seat_expiration(seat),
        }
Exemple #13
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)

        # Verify created objects match mocked data
        parent_seat = course.parent_seat_product
        self.assertEqual(parent_seat.title, 'Seat in {}'.format(self.course_name))
        self.assertEqual(course.verification_deadline, EXPIRES)

        for seat in course.seat_products:
            mode = mode_for_product(seat)
            logger.info('Validating objects for [%s] mode...', mode)

            self.assert_stock_record_valid(seat.stockrecords.first(), seat, Decimal(self.prices[mode]))
Exemple #14
0
def send_course_purchase_email(sender, order=None, **kwargs):  # pylint: disable=unused-argument
    """Send course purchase notification email when a course is purchased."""
    if waffle.switch_is_active('ENABLE_NOTIFICATIONS'):
        # We do not currently support email sending for orders with more than one item.
        if len(order.lines.all()) == ORDER_LINE_COUNT:
            product = order.lines.first().product
            credit_provider_id = getattr(product.attr, 'credit_provider', None)
            product_mode = mode_for_product(product)
            if not credit_provider_id:
                if product_mode != 'credit':
                    # send course purchase email for verified courses
                    send_notification(order.user, 'COURSE_PURCHASED', {
                        'course_title': product.title,
                    }, order.site)
                else:
                    logger.error(
                        'Failed to send credit receipt notification. Credit seat product [%s] has no provider.',
                        product.id)
                    return
            elif product.is_seat_product:
                provider_data = get_credit_provider_details(
                    access_token=order.site.siteconfiguration.access_token,
                    credit_provider_id=credit_provider_id,
                    site_configuration=order.site.siteconfiguration)

                receipt_page_url = get_receipt_page_url(
                    order_number=order.number,
                    site_configuration=order.site.siteconfiguration)

                if provider_data:
                    send_notification(
                        order.user, 'CREDIT_RECEIPT', {
                            'course_title': product.title,
                            'receipt_page_url': receipt_page_url,
                            'credit_hours': product.attr.credit_hours,
                            'credit_provider': provider_data['display_name'],
                        }, order.site)

        else:
            logger.info(
                'Currently support receipt emails for order with one item.')
Exemple #15
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_product(line.product),
        'name': course.id if course else line.product.title,
        'price': float(line.line_price_excl_tax),
        'quantity': int(line.quantity),
        'category': line.product.get_product_class().name,
    }
Exemple #16
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)
Exemple #17
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, request=request)
    basket_add_enterprise_catalog_attribute(basket, request.GET)
    basket.flush()
    basket.save()
    basket_addition = get_class('basket.signals', 'basket_addition')
    already_purchased_products = []
    bundle = request.GET.get('bundle')

    _set_basket_bundle_status(bundle, basket)

    if request.site.siteconfiguration.enable_embargo_check:
        if not embargo_check(request.user, request.site, products):
            messages.error(
                request,
                _('Due to export controls, we cannot allow you to access this course at this time.'
                  ))
            logger.warning(
                'User [%s] blocked by embargo check, not adding products to basket',
                request.user.username)
            return basket

    is_multi_product_basket = len(products) > 1
    for product in products:
        if product.is_enrollment_code_product or \
                not UserAlreadyPlacedOrder.user_already_placed_order(user=request.user,
                                                                     product=product, site=request.site):
            basket.add_product(product, 1)
            # Call signal handler to notify listeners that something has been added to the basket
            basket_addition.send(
                sender=basket_addition,
                product=product,
                user=request.user,
                request=request,
                basket=basket,
                is_multi_product_basket=is_multi_product_basket)
        else:
            already_purchased_products.append(product)
            logger.warning(
                'User [%s] attempted to repurchase the [%s] seat of course [%s]',
                request.user.username, mode_for_product(product),
                product.course_id)
    if already_purchased_products and basket.is_empty:
        raise AlreadyPlacedOrderException

    if len(products) == 1 and products[0].is_enrollment_code_product:
        basket.clear_vouchers()
    elif voucher or basket.vouchers.exists():
        voucher = voucher or basket.vouchers.first()
        basket.clear_vouchers()
        is_valid, message = validate_voucher(voucher, request.user, basket,
                                             request.site)
        if is_valid:
            apply_voucher_on_basket_and_check_discount(voucher, request,
                                                       basket)
        else:
            logger.warning(
                '[Code Redemption Failure] The voucher is not valid for this basket. '
                'User: %s, Basket: %s, Code: %s, Message: %s',
                request.user.username, request.basket.id, voucher.code,
                message)
    logger.info(
        '----------------------Baket Currency------------------------ %s',
        basket.currency)
    attribute_cookie_data(basket, request)
    return basket
Exemple #18
0
    def fulfill_product(self, order, lines):
        """ Fulfills the purchase of a 'Course Entitlement'.
        Uses the order and the lines to determine which courses to grant an entitlement for, and with certain
        certificate types. May result in an error if the Entitlement API cannot be reached, or if there is
        additional business logic errors when trying grant the entitlement.
        Args:
            order (Order): The Order associated with the lines to be fulfilled. The user associated with the order
                is presumed to be the student to grant an entitlement.
            lines (List of Lines): Order Lines, associated with purchased products in an Order. These should only
                be "Course Entitlement" products.
        Returns:
            The original set of lines, with new statuses set based on the success or failure of fulfillment.
        """
        logger.info('Attempting to fulfill "Course Entitlement" product types for order [%s]', order.number)

        for line in lines:
            try:
                mode = mode_for_product(line.product)
                UUID = line.product.attr.UUID
            except AttributeError:
                logger.error('Entitlement Product does not have required attributes, [certificate_type, UUID]')
                line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR)
                continue

            data = {
                'user': order.user.username,
                'course_uuid': UUID,
                'mode': mode,
                'order_number': order.number,
            }

            try:
                entitlement_option = Option.objects.get(code='course_entitlement')

                entitlement_api_client = EdxRestApiClient(
                    get_lms_entitlement_api_url(),
                    jwt=order.site.siteconfiguration.access_token
                )

                # POST to the Entitlement API.
                response = entitlement_api_client.entitlements.post(data)
                line.attributes.create(option=entitlement_option, value=response['uuid'])
                line.set_status(LINE.COMPLETE)

                audit_log(
                    'line_fulfilled',
                    order_line_id=line.id,
                    order_number=order.number,
                    product_class=line.product.get_product_class().name,
                    UUID=UUID,
                    mode=mode,
                    user_id=order.user.id,
                )
            except (Timeout, ConnectionError):
                logger.exception(
                    'Unable to fulfill line [%d] of order [%s] due to a network problem', line.id, order.number
                )
                line.set_status(LINE.FULFILLMENT_NETWORK_ERROR)
            except Exception:  # pylint: disable=broad-except
                logger.exception(
                    'Unable to fulfill line [%d] of order [%s]', line.id, order.number
                )
                line.set_status(LINE.FULFILLMENT_SERVER_ERROR)

        logger.info('Finished fulfilling "Course Entitlement" product types for order [%s]', order.number)
        return order, lines
Exemple #19
0
    def fulfill_product(self, order, lines, email_opt_in=False):
        """ Fulfills the purchase of a 'Course Entitlement'.
        Uses the order and the lines to determine which courses to grant an entitlement for, and with certain
        certificate types. May result in an error if the Entitlement API cannot be reached, or if there is
        additional business logic errors when trying grant the entitlement.

        Updates the user's email preferences based on email_opt_in as a side effect.

        Args:
            order (Order): The Order associated with the lines to be fulfilled. The user associated with the order
                is presumed to be the student to grant an entitlement.
            lines (List of Lines): Order Lines, associated with purchased products in an Order. These should only
                be "Course Entitlement" products.
            email_opt_in (bool): Whether the user should be opted in to emails
                as part of the fulfillment. Defaults to False.

        Returns:
            The original set of lines, with new statuses set based on the success or failure of fulfillment.
        """
        logger.info(
            'Attempting to fulfill "Course Entitlement" product types for order [%s]',
            order.number)

        for line in lines:
            try:
                mode = mode_for_product(line.product)
                UUID = line.product.attr.UUID
            except AttributeError:
                logger.error(
                    'Entitlement Product does not have required attributes, [certificate_type, UUID]'
                )
                line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR)
                continue

            data = {
                'user': order.user.username,
                'course_uuid': UUID,
                'mode': mode,
                'order_number': order.number,
                'email_opt_in': email_opt_in,
            }

            try:
                self._create_enterprise_customer_user(order)
                self.update_orderline_with_enterprise_discount_metadata(
                    order, line)
                entitlement_option = Option.objects.get(
                    code='course_entitlement')

                api_client = line.order.site.siteconfiguration.oauth_api_client
                entitlement_url = urljoin(get_lms_entitlement_api_url(),
                                          'entitlements/')

                # POST to the Entitlement API.
                response = api_client.post(entitlement_url, json=data)
                response.raise_for_status()
                response = response.json()
                line.attributes.create(option=entitlement_option,
                                       value=response['uuid'])
                line.set_status(LINE.COMPLETE)

                audit_log(
                    'line_fulfilled',
                    order_line_id=line.id,
                    order_number=order.number,
                    product_class=line.product.get_product_class().name,
                    UUID=UUID,
                    mode=mode,
                    user_id=order.user.id,
                )
            except (Timeout, ReqConnectionError):
                logger.exception(
                    'Unable to fulfill line [%d] of order [%s] due to a network problem',
                    line.id, order.number)
                order.notes.create(
                    message=
                    'Fulfillment of order failed due to a network problem.',
                    note_type='Error')
                line.set_status(LINE.FULFILLMENT_NETWORK_ERROR)
            except Exception:  # pylint: disable=broad-except
                logger.exception('Unable to fulfill line [%d] of order [%s]',
                                 line.id, order.number)
                order.notes.create(
                    message='Fulfillment of order failed due to an Exception.',
                    note_type='Error')
                line.set_status(LINE.FULFILLMENT_SERVER_ERROR)

        logger.info(
            'Finished fulfilling "Course Entitlement" product types for order [%s]',
            order.number)
        return order, lines
Exemple #20
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_product(line.product)
                course_key = line.product.attr.course_key
            except AttributeError:
                logger.error("Supported Seat Product does not have required attributes, [certificate_type, course_key]")
                line.set_status(LINE.FULFILLMENT_CONFIGURATION_ERROR)
                continue
            try:
                provider = line.product.attr.credit_provider
            except AttributeError:
                logger.debug("Seat [%d] has no credit_provider attribute. Defaulted to None.", line.product.id)
                provider = None

            data = {
                'user': order.user.username,
                'is_active': True,
                'mode': mode,
                'course_details': {
                    'course_id': course_key
                },
                'enrollment_attributes': [
                    {
                        'namespace': 'order',
                        'name': 'order_number',
                        'value': order.number
                    }
                ]
            }
            if provider:
                data['enrollment_attributes'].append(
                    {
                        'namespace': 'credit',
                        'name': 'provider_id',
                        'value': provider
                    }
                )
            try:
                self._add_enterprise_data_to_enrollment_api_post(data, order)

                # Post to the Enrollment API. The LMS will take care of posting a new EnterpriseCourseEnrollment to
                # the Enterprise service if the user+course has a corresponding EnterpriseCustomerUser.
                response = self._post_to_enrollment_api(data, user=order.user)

                if response.status_code == status.HTTP_200_OK:
                    line.set_status(LINE.COMPLETE)

                    audit_log(
                        'line_fulfilled',
                        order_line_id=line.id,
                        order_number=order.number,
                        product_class=line.product.get_product_class().name,
                        course_id=course_key,
                        mode=mode,
                        user_id=order.user.id,
                        credit_provider=provider,
                    )
                else:
                    try:
                        data = response.json()
                        reason = data.get('message')
                    except Exception:  # pylint: disable=broad-except
                        reason = '(No detail provided.)'

                    logger.error(
                        "Fulfillment of line [%d] on order [%s] failed with status code [%d]: %s",
                        line.id, order.number, response.status_code, reason
                    )
                    line.set_status(LINE.FULFILLMENT_SERVER_ERROR)
            except ConnectionError:
                logger.error(
                    "Unable to fulfill line [%d] of order [%s] due to a network problem", line.id, order.number
                )
                line.set_status(LINE.FULFILLMENT_NETWORK_ERROR)
            except Timeout:
                logger.error(
                    "Unable to fulfill line [%d] of order [%s] due to a request time out", line.id, order.number
                )
                line.set_status(LINE.FULFILLMENT_TIMEOUT_ERROR)
        logger.info("Finished fulfilling 'Seat' product types for order [%s]", order.number)
        return order, lines
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 = []
    bundle = request.GET.get('bundle')

    if bundle:
        BasketAttribute.objects.update_or_create(
            basket=basket,
            attribute_type=BasketAttributeType.objects.get(name=BUNDLE),
            defaults={'value_text': bundle})
        basket.clear_vouchers()
    else:
        BasketAttribute.objects.filter(basket=basket,
                                       attribute_type__name=BUNDLE).delete()

    if request.site.siteconfiguration.enable_embargo_check:
        if not embargo_check(request.user, request.site, products):
            messages.error(
                request,
                _('Due to export controls, we cannot allow you to access this course at this time.'
                  ))
            logger.warning(
                'User [%s] blocked by embargo check, not adding products to basket',
                request.user.username)
            return basket

    is_multi_product_basket = True if len(products) > 1 else False
    for product in products:
        if product.is_enrollment_code_product or \
                not UserAlreadyPlacedOrder.user_already_placed_order(user=request.user,
                                                                     product=product, site=request.site):
            basket.add_product(product, 1)
            # Call signal handler to notify listeners that something has been added to the basket
            basket_addition.send(
                sender=basket_addition,
                product=product,
                user=request.user,
                request=request,
                basket=basket,
                is_multi_product_basket=is_multi_product_basket)
        else:
            already_purchased_products.append(product)
            logger.warning(
                'User [%s] attempted to repurchase the [%s] seat of course [%s]',
                request.user.username, mode_for_product(product),
                product.course_id)
    if already_purchased_products and basket.is_empty:
        raise AlreadyPlacedOrderException

    if len(products) == 1 and products[0].is_enrollment_code_product:
        basket.clear_vouchers()
    elif voucher:
        basket.clear_vouchers()
        basket.vouchers.add(voucher)
        Applicator().apply(basket, request.user, request)
        logger.info('Applied Voucher [%s] to basket [%s].', voucher.code,
                    basket.id)

    attribute_cookie_data(basket, request)
    return basket
Exemple #22
0
    def mock_program_detail_endpoint(self,
                                     program_uuid,
                                     discovery_api_url,
                                     empty=False,
                                     title='Test Program',
                                     include_entitlements=True,
                                     status='active'):
        """ Mocks the program detail endpoint on the Catalog API.
        Args:
            program_uuid (uuid): UUID of the mocked program.

        Returns:
            dict: Mocked program data.
        """
        partner = PartnerFactory()
        data = None
        if not empty:
            courses = []
            for i in range(1, 5):
                uuid = '268afbfc-cc1e-415b-a5d8-c58d955bcfc' + str(i)
                entitlement = create_or_update_course_entitlement(
                    'verified', 10, partner, uuid, uuid)
                entitlements = []
                if include_entitlements:
                    entitlements.append({
                        "mode":
                        "verified",
                        "price":
                        "10.00",
                        "currency":
                        "USD",
                        "sku":
                        entitlement.stockrecords.first().partner_sku
                    })
                key = 'course-v1:test-org+course+' + str(i)
                course_runs = []
                for __ in range(1, 4):
                    course_run = CourseFactory(partner=self.partner)
                    course_run.create_or_update_seat('audit', False,
                                                     Decimal(0))
                    course_run.create_or_update_seat('verified', True,
                                                     Decimal(100))

                    course_runs.append({
                        'key':
                        course_run.id,
                        'seats': [{
                            'type':
                            mode_for_product(seat),
                            'sku':
                            seat.stockrecords.get(
                                partner=self.partner).partner_sku,
                        } for seat in course_run.seat_products]
                    })

                courses.append({
                    'key': key,
                    'uuid': uuid,
                    'course_runs': course_runs,
                    'entitlements': entitlements,
                })

            program_uuid = str(program_uuid)
            data = {
                'uuid': program_uuid,
                'title': title,
                'status': status,
                'type': 'MicroMockers',
                'courses': courses,
                'applicable_seat_types':
                ['verified', 'professional', 'credit'],
            }
        self.mock_access_token_response()
        httpretty.register_uri(method=httpretty.GET,
                               uri='{base}/programs/{uuid}/'.format(
                                   base=discovery_api_url.strip('/'),
                                   uuid=program_uuid),
                               body=json.dumps(data),
                               content_type='application/json')
        return data
Exemple #23
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
Exemple #24
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

    site_configuration = order.site.siteconfiguration
    if not site_configuration.enable_sailthru:
        return

    # get campaign id from cookies, or saved value in basket
    message_id = None
    if request:
        message_id = request.COOKIES.get('sailthru_bid')

    if not message_id:
        saved_id = BasketAttribute.objects.filter(
            basket=order.basket, attribute_type=get_basket_attribute_type())
        if len(saved_id) > 0:
            message_id = saved_id[0].value_text

    # loop through lines in order
    #  If multi product orders become common it may be worthwhile to pass an array of
    #  orders to the worker in one call to save overhead, however, that would be difficult
    #  because of the fact that there are different templates for free enroll versus paid enroll
    lines = order.lines.all()
    # We are not sending multi product orders to sailthru for now, because
    # the abandoned cart email does not yet support baskets with multiple products
    if len(lines) > 1:
        return
    for line in lines:
        # get product
        product = line.product
        sku = line.partner_sku

        # ignore everything except course seats.  no support for coupons as of yet
        if product.is_seat_product:
            price = line.line_price_excl_tax
            course_id = product.course_id

            # Tell Sailthru that the purchase is complete asynchronously
            update_course_enrollment.delay(
                order.user.email,
                _build_course_url(course_id),
                False,
                mode_for_product(product),
                unit_cost=price,
                course_id=course_id,
                currency=order.currency,
                site_code=site_configuration.partner.short_code,
                message_id=message_id,
                sku=sku)