Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Enable mobile payments for new upcoming courses #15

Open
wants to merge 2 commits into
base: 2u/main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions ecommerce/extensions/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@
)
from ecommerce.extensions.catalogue.utils import attach_vouchers_to_coupon_product
from ecommerce.extensions.checkout.views import ReceiptResponseView
from ecommerce.extensions.iap.api.v1.utils import apply_price_of_inapp_purchase, get_auth_headers
from ecommerce.extensions.iap.api.v1.utils import apply_price_of_inapp_purchase, create_ios_product, get_auth_headers
from ecommerce.extensions.iap.constants import ANDROID_SKU_PREFIX, CREATE_APPSTORE_PRODUCTS_FOR_INAPP, IOS_SKU_PREFIX
from ecommerce.extensions.iap.processors.ios_iap import IOSIAP
from ecommerce.extensions.iap.utils import create_mobile_seat
from ecommerce.extensions.offer.constants import (
ASSIGN,
AUTOMATIC_EMAIL,
Expand Down Expand Up @@ -827,13 +829,30 @@ def validate_products(self, products):

return products

def _update_mobile_seats(self, course):
def _update_or_create_mobile_seats(self, course):
certificate_type_query = Q(attributes__name='certificate_type',
attribute_values__value_text=CertificateType.VERIFIED)
mobile_query = Q(stockrecords__partner_sku__contains='mobile')
seat_products = course.seat_products
mobile_seats = seat_products.filter(certificate_type_query & mobile_query)
web_seat = seat_products.filter(certificate_type_query & ~mobile_query).first()
if mobile_seats:
self._update_mobile_seats(mobile_seats, web_seat, course)
else:
logger.info("Creating mobile seats for course [%s]", course.id)
create_mobile_seat(ANDROID_SKU_PREFIX, web_seat)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap create_mobile_seat calls in try-except to catch specific failures (e.g., Android or iOS creation).
something like this

Suggested change
create_mobile_seat(ANDROID_SKU_PREFIX, web_seat)
try:
create_mobile_seat(ANDROID_SKU_PREFIX, web_seat)
except Exception as e:
logger.error("Failed to create Android mobile seat for course [%s]: %s", course.id, str(e))
return

ios_seat = create_mobile_seat(IOS_SKU_PREFIX, web_seat)
if waffle.switch_is_active(CREATE_APPSTORE_PRODUCTS_FOR_INAPP):
partner_short_code = self.context['request'].site.siteconfiguration.partner.short_code
configuration = settings.PAYMENT_PROCESSOR_CONFIG[partner_short_code.lower()][IOSIAP.NAME.lower()]
course_data = {
'price': ios_seat.price_excl_tax,
'name': course.name,
'key': course.id
}
create_ios_product(course_data, ios_seat, configuration)

def _update_mobile_seats(self, mobile_seats, web_seat, course):
failure_msg = False
try:
for mobile_seat in mobile_seats:
Expand Down Expand Up @@ -925,9 +944,8 @@ def save(self): # pylint: disable=arguments-differ

resp_message = course.publish_to_lms()
published = (resp_message is None)

if published:
self._update_mobile_seats(course)
self._update_or_create_mobile_seats(course)

return created, None, None
raise Exception(resp_message)
Expand Down
36 changes: 27 additions & 9 deletions ecommerce/extensions/api/v2/tests/views/test_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from ecommerce.entitlements.utils import create_or_update_course_entitlement
from ecommerce.extensions.api.v2.tests.views import JSON_CONTENT_TYPE
from ecommerce.extensions.catalogue.tests.mixins import DiscoveryTestMixin
from ecommerce.extensions.iap.constants import CREATE_APPSTORE_PRODUCTS_FOR_INAPP
from ecommerce.extensions.iap.utils import create_child_products_for_mobile
from ecommerce.tests.testcases import TestCase

Expand Down Expand Up @@ -158,6 +159,7 @@ def setUp(self):
self.client.login(username=self.user.username, password=self.password)

self.publication_switch = toggle_switch('publish_course_modes_to_lms', True)
self.appstore_product_switch = toggle_switch(CREATE_APPSTORE_PRODUCTS_FOR_INAPP, False)

def _toggle_publication(self, is_enabled):
"""Toggle LMS publication."""
Expand Down Expand Up @@ -233,7 +235,7 @@ def assert_entitlement_saved(self, course, expected):
self.assertEqual(entitlement.attr.UUID, self.course_uuid)
self.assertEqual(entitlement.stockrecords.get(partner=self.partner).price_excl_tax, expected['price'])

def assert_seat_saved(self, course, expected, test_mobile_seats=False):
def assert_seat_saved(self, course, expected, test_mobile_seats=True):
certificate_type = ''
verified_product = False

Expand All @@ -258,17 +260,17 @@ def assert_seat_saved(self, course, expected, test_mobile_seats=False):
self.assertEqual(seat.stockrecords.get(partner=self.partner).price_excl_tax, expected['price'])

if test_mobile_seats and verified_product:
android_seat = course.seat_products.get(title='Android ' + seat_title)
android_seat = course.seat_products.get(title='Android ' + seat_title.lower())
self.assertEqual(android_seat.expires, expires)
self.assertEqual(android_seat.stockrecords.get(partner=self.partner).price_excl_tax, expected['price'])

ios_seat = course.seat_products.get(title='Ios ' + seat_title)
ios_seat = course.seat_products.get(title='Ios ' + seat_title.lower())
self.assertEqual(ios_seat.expires, expires)
self.assertEqual(ios_seat.stockrecords.get(partner=self.partner).price_excl_tax, expected['price'])

return seat

def assert_course_saved(self, course_id, expected, enrollment_code_count=0, test_mobile_seats=False):
def assert_course_saved(self, course_id, expected, enrollment_code_count=0, test_mobile_seats=True):
"""Verify that the expected Course and associated products have been saved."""
# Verify that Course was saved.
self.assertTrue(Course.objects.filter(id=course_id).exists())
Expand Down Expand Up @@ -343,7 +345,7 @@ def test_update(self):
response = self.client.put(self.update_path, json.dumps(updated_data), JSON_CONTENT_TYPE)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.data.get('error'), error_msg)
self.assert_course_saved(self.course_id, expected=self.data)
self.assert_course_saved(self.course_id, expected=self.data, test_mobile_seats=False)

# If publication succeeds, the view should return a 200 and data should be saved.
mock_publish.return_value = None
Expand Down Expand Up @@ -512,8 +514,8 @@ def test_mobile_seats_update(self, _, __):

# Since we are only concerned with expiry date and price
# therefore we are setting title manually here.
android_seat.product.title = 'Android Seat in A New Name with verified certificate'
ios_seat.product.title = 'Ios Seat in A New Name with verified certificate'
android_seat.product.title = 'Android seat in a new name with verified certificate'
ios_seat.product.title = 'Ios seat in a new name with verified certificate'
android_seat.product.save()
ios_seat.product.save()
with mock.patch.object(LMSPublisher, 'publish') as mock_publish:
Expand All @@ -523,5 +525,21 @@ def test_mobile_seats_update(self, _, __):
response = self.client.put(self.update_path, json.dumps(updated_data), JSON_CONTENT_TYPE)

self.assertEqual(response.status_code, 200)
self.assert_course_saved(self.course_id, expected=updated_data,
enrollment_code_count=1, test_mobile_seats=True)
self.assert_course_saved(self.course_id, expected=updated_data, enrollment_code_count=1)

@mock.patch('ecommerce.extensions.api.serializers.create_ios_product')
def test_ios_seat_created(self, mock_create_ios_product):
"""Verify that a Course and associated mobile products can be updated and published."""
self.create_course_and_seats()
updated_data = self.generate_update_payload()

with mock.patch.object(LMSPublisher, 'publish') as mock_publish:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you remove mock from here and add something like this

Suggested change
with mock.patch.object(LMSPublisher, 'publish') as mock_publish:
@mock.patch.object(LMSPublisher, 'publish', return_value=None)
def test_course_update_creates_ios_seat_when_feature_enabled(self, mock_publish, mock_create_ios_product):

# If publication succeeds, the view should return a 200 and data should be saved.
mock_publish.return_value = None
self.appstore_product_switch.active = True
self.appstore_product_switch.save()

response = self.client.put(self.update_path, json.dumps(updated_data), JSON_CONTENT_TYPE)
mock_create_ios_product.assert_called_once()
self.assertEqual(response.status_code, 200)
self.assert_course_saved(self.course_id, expected=updated_data, enrollment_code_count=1)
27 changes: 15 additions & 12 deletions ecommerce/extensions/iap/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import app_store_notifications_v2_validator as asn2
import httplib2
import waffle
from django.conf import settings
from django.db import transaction
from django.utils.decorators import method_decorator
Expand Down Expand Up @@ -84,6 +85,7 @@
from ecommerce.extensions.iap.api.v1.exceptions import RefundCompletionException
from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer
from ecommerce.extensions.iap.api.v1.utils import create_ios_product, products_in_basket_already_purchased
from ecommerce.extensions.iap.constants import CREATE_APPSTORE_PRODUCTS_FOR_INAPP
from ecommerce.extensions.iap.models import IAPProcessorConfiguration
from ecommerce.extensions.iap.processors.android_iap import AndroidIAP
from ecommerce.extensions.iap.processors.ios_iap import IOSIAP
Expand Down Expand Up @@ -487,18 +489,19 @@ def post(self, request):
course.publish_to_lms()
created_skus[course_run_key] = [mobile_products[0].partner_sku, mobile_products[1].partner_sku]

# create ios product on appstore
partner_short_code = request.site.siteconfiguration.partner.short_code
configuration = settings.PAYMENT_PROCESSOR_CONFIG[partner_short_code.lower()][IOSIAP.NAME.lower()]
ios_product = list((filter(lambda sku: 'ios' in sku.partner_sku, mobile_products)))[0]
course_data = {
'price': ios_product.price_excl_tax,
'name': course.name,
'key': course_run_key
}
error_msg = create_ios_product(course_data, ios_product, configuration)
if error_msg:
failed_ios_products.append(error_msg)
if waffle.switch_is_active(CREATE_APPSTORE_PRODUCTS_FOR_INAPP):
# create ios product on appstore
partner_short_code = request.site.siteconfiguration.partner.short_code
configuration = settings.PAYMENT_PROCESSOR_CONFIG[partner_short_code.lower()][IOSIAP.NAME.lower()]
ios_product = list((filter(lambda sku: 'ios' in sku.partner_sku, mobile_products)))[0]
course_data = {
'price': ios_product.price_excl_tax,
'name': course.name,
'key': course_run_key
}
error_msg = create_ios_product(course_data, ios_product, configuration)
if error_msg:
failed_ios_products.append(error_msg)

result = {
'new_mobile_skus': created_skus,
Expand Down
10 changes: 10 additions & 0 deletions ecommerce/extensions/iap/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,13 @@
ANDROID_SKU_PREFIX = 'android'
IOS_SKU_PREFIX = 'ios'
MISSING_WEB_SEAT_ERROR = "Couldn't find existing web seat for course [%s]"

# .. toggle_name: create_appstore_products_for_inapp
# .. toggle_type: waffle_switch
# .. toggle_default: False
# .. toggle_description: Create ios products on appstore using Apple in-app apis.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2023-07-25
# .. toggle_tickets: LEARNER-9951
# .. toggle_status: supported
CREATE_APPSTORE_PRODUCTS_FOR_INAPP = 'create_appstore_products_for_inapp'
Loading