Skip to content
This repository has been archived by the owner on Nov 4, 2024. It is now read-only.

feat: create ios products on appstore for given course key #4090

Merged
merged 5 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions ecommerce/extensions/iap/api/v1/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
ERROR_DURING_POST_ORDER_OP = "An error occurred during post order operations."
FOUND_MULTIPLE_PRODUCTS_ERROR = "Found unexpected number of products for course [%s]"
GOOGLE_PUBLISHER_API_SCOPE = "https://www.googleapis.com/auth/androidpublisher"
IOS_PRODUCT_REVIEW_NOTE = ('This in-app purchase will unlock all the content of the course {course_name}\n\n'
'For testing the end-to-end payment flow, please follow the following steps:\n1. '
'Go to the Discover tab\n2. Search for "{course_name}"\n3. Enroll in the course'
' "{course_name}"\n4. Hit \"Upgrade to access more features\", it will open a '
'detail unlock features page\n5. Hit "Upgrade now for ${course_price}" from the'
' detail page')
IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE = "Ignoring notification from apple since we are only expecting" \
" refund notifications"
LOGGER_BASKET_ALREADY_PURCHASED = "Basket creation failed for user [%s] with SKUS [%s]. Products already purchased"
Expand Down
22 changes: 13 additions & 9 deletions ecommerce/extensions/iap/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1074,14 +1074,17 @@ def test_empty_list(self):
self.assertEqual(response.status_code, 200)
result = json.loads(response.content)
expected_result = {
"new_mobile_skus": {},
"failed_course_ids": [],
"missing_course_runs": []
'new_mobile_skus': {},
'failed_course_ids': [],
'missing_course_runs': [],
'failed_ios_products': []
}
self.assertEqual(result, expected_result)

def test_missing_and_new_skus_in_course(self):
@mock.patch('ecommerce.extensions.iap.api.v1.views.create_ios_product')
def test_missing_and_new_skus_in_course(self, create_ios_product_patch):
""" Verify the view differentiate between a correct and non-existent course id """
create_ios_product_patch.return_value = None
with LogCapture(self.logger_name) as logger:
post_data = {"courses": ["course:wrong-id", self.course.id]}
response = self.client.post(self.path, data=post_data, content_type="application/json")
Expand All @@ -1092,15 +1095,16 @@ def test_missing_and_new_skus_in_course(self):
stock_record = StockRecord.objects.get(product=self.product)

expected_result = {
"new_mobile_skus": {
'new_mobile_skus': {
self.course.id:
[
"mobile.android.{}".format(stock_record.partner_sku.lower()),
"mobile.ios.{}".format(stock_record.partner_sku.lower()),
'mobile.android.{}'.format(stock_record.partner_sku.lower()),
'mobile.ios.{}'.format(stock_record.partner_sku.lower()),
]
},
"failed_course_ids": [],
"missing_course_runs": ['course:wrong-id']
'failed_course_ids': [],
'missing_course_runs': ['course:wrong-id'],
'failed_ios_products': []
}

self.assertEqual(result, expected_result)
289 changes: 288 additions & 1 deletion ecommerce/extensions/iap/api/v1/utils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import logging
import time


import jwt
import requests
from django.contrib.staticfiles.storage import staticfiles_storage
from oscar.core.loading import get_model
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

from ecommerce.extensions.iap.api.v1.constants import IOS_PRODUCT_REVIEW_NOTE
from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder

Product = get_model('catalogue', 'Product')
APP_STORE_BASE_URL = "https://api.appstoreconnect.apple.com"
logger = logging.getLogger(__name__)


def products_in_basket_already_purchased(user, basket, site):
Expand All @@ -17,3 +27,280 @@
UserAlreadyPlacedOrder.user_already_placed_order(user=user, product=product, site=site):
return True
return False


def create_ios_product(course, ios_sku, configuration):
"""
Create in app ios product on connect store.
return error message in case of failure.
"""
headers = get_auth_headers(configuration)
try:
in_app_purchase_id = create_inapp_purchase(course, ios_sku, configuration['apple_id'], headers)
localize_inapp_purchase(in_app_purchase_id, headers)
apply_price_of_inapp_purchase(course['price'], in_app_purchase_id, headers)
upload_screenshot_of_inapp_purchase(in_app_purchase_id, headers)
return submit_in_app_purchase_for_review(in_app_purchase_id, headers)
except AppStoreRequestException as store_exception:
sku_error_msg = "{} for course {} with sku {}".format(str(store_exception), course['key'], ios_sku)
logger.error(sku_error_msg)
return sku_error_msg

Check warning on line 47 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L37-L47

Added lines #L37 - L47 were not covered by tests


def request_connect_store(url, headers, data=None, method="post"):
""" Request the given endpoint with multiple tries and backoff time """
http = Session()
retries = Retry(

Check warning on line 53 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L52-L53

Added lines #L52 - L53 were not covered by tests
total=3,
backoff_factor=3,
Copy link
Contributor

Choose a reason for hiding this comment

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

total and backoff_factor, would they ever have a different value? Should they be hard coded? Or can you add a comment on what this is/how this could change for the future

status_forcelist=[502, 503, 504, 408, 429, 409],
method_whitelist={'POST', "GET", "PUT", "PATCH"},
)
http.mount('https://', HTTPAdapter(max_retries=retries))
try:
if method == "post":
response = http.post(url, json=data, headers=headers)
if method == "patch":
response = http.patch(url, json=data, headers=headers)
if method == "put":
response = http.put(url, data=data, headers=headers)
if method == "get":
response = http.get(url, headers=headers)
julianajlk marked this conversation as resolved.
Show resolved Hide resolved
except requests.RequestException as request_exc:
raise AppStoreRequestException(request_exc) from request_exc

Check warning on line 70 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L59-L70

Added lines #L59 - L70 were not covered by tests

return response

Check warning on line 72 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L72

Added line #L72 was not covered by tests


def get_auth_headers(configuration):
""" Get Bearer token with headers to call appstore """

headers = {

Check warning on line 78 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L78

Added line #L78 was not covered by tests
"kid": configuration['key_id'],
"typ": "JWT",
"alg": "ES256"
}

payload = {

Check warning on line 84 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L84

Added line #L84 was not covered by tests
"iss": configuration['issuer_id'],
"exp": round(time.time()) + 60 * 20, # Token expiration time (20 minutes)
"aud": "appstoreconnect-v1",
"bid": configuration['ios_bundle_id']
}

private_key = configuration['private_key']
token = jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
headers = {

Check warning on line 93 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L91-L93

Added lines #L91 - L93 were not covered by tests
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
return headers

Check warning on line 97 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L97

Added line #L97 was not covered by tests


def create_inapp_purchase(course, ios_sku, apple_id, headers):
""" Create in app product and return its id. """

url = APP_STORE_BASE_URL + "/v2/inAppPurchases"
data = {

Check warning on line 104 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L103-L104

Added lines #L103 - L104 were not covered by tests
"data": {
"type": "inAppPurchases",
"attributes": {
"name": course['key'],
"productId": ios_sku,
"inAppPurchaseType": "NON_CONSUMABLE",
"reviewNote": IOS_PRODUCT_REVIEW_NOTE.format(course_name=course['name'],
course_price=course['price']),
"availableInAllTerritories": True
},
"relationships": {
"app": {
"data": {
"type": "apps",
"id": apple_id
}
}
}
}
}
response = request_connect_store(url=url, data=data, headers=headers)
if response.status_code == 201:
return response.json()["data"]["id"]

Check warning on line 127 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L125-L127

Added lines #L125 - L127 were not covered by tests

raise AppStoreRequestException("Couldn't create inapp purchase id")

Check warning on line 129 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L129

Added line #L129 was not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

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

You could add the request call on a try/except, unless assuming if status_code is anything other than 201 it will always be the same exception and it won't error out before it gets to the raise

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can you explain it a little?



def localize_inapp_purchase(in_app_purchase_id, headers):
""" Localize given in app product with US locale. """

url = APP_STORE_BASE_URL + "/v1/inAppPurchaseLocalizations"
data = {

Check warning on line 136 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L135-L136

Added lines #L135 - L136 were not covered by tests
"data": {
"type": "inAppPurchaseLocalizations",
"attributes": {
"locale": "en-US",
"name": "Upgrade Course",
"description": "Unlock course activities & certificate"
},
"relationships": {
"inAppPurchaseV2": {
"data": {
"type": "inAppPurchases",
"id": in_app_purchase_id
}
}
}
}
}
response = request_connect_store(url=url, data=data, headers=headers)
if not response.status_code == 201:
raise AppStoreRequestException("Couldn't localize purchase")

Check warning on line 156 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L154-L156

Added lines #L154 - L156 were not covered by tests


def apply_price_of_inapp_purchase(price, in_app_purchase_id, headers):
""" Apply price tier to the given in app product. """

url = APP_STORE_BASE_URL + ("/v2/inAppPurchases/v2/inAppPurchases/{}/pricePoints?filter[territory]=USA"

Check warning on line 162 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L162

Added line #L162 was not covered by tests
"&include=territory&limit=8000").format(in_app_purchase_id)

response = request_connect_store(url=url, headers=headers, method='get')
if response.status_code != 200:
raise AppStoreRequestException("Couldn't fetch price points")

Check warning on line 167 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L165-L167

Added lines #L165 - L167 were not covered by tests

nearest_low_price = nearest_low_price_id = 0
for price_point in response.json()['data']:
customer_price = float(price_point['attributes']['customerPrice'])
if nearest_low_price < customer_price <= price:
nearest_low_price = customer_price
nearest_low_price_id = price_point['id']

Check warning on line 174 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L169-L174

Added lines #L169 - L174 were not covered by tests

if not nearest_low_price:
raise AppStoreRequestException("Couldn't find nearest low price point")

Check warning on line 177 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L176-L177

Added lines #L176 - L177 were not covered by tests

url = APP_STORE_BASE_URL + "/v1/inAppPurchasePriceSchedules"
data = {

Check warning on line 180 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L179-L180

Added lines #L179 - L180 were not covered by tests
"data": {
"type": "inAppPurchasePriceSchedules",
"attributes": {},
"relationships": {
"inAppPurchase": {
"data": {
"type": "inAppPurchases",
"id": in_app_purchase_id
}
},
"manualPrices": {
"data": [
{
"type": "inAppPurchasePrices",
"id": "${price}"
}
]
},
"baseTerritory": {
"data": {
"type": "territories",
"id": "USA"
}
}
}
},
"included": [
{
"id": "${price}",
"relationships": {
"inAppPurchasePricePoint": {
"data": {
"type": "inAppPurchasePricePoints",
"id": nearest_low_price_id
}
}
},
"type": "inAppPurchasePrices",
"attributes": {
"startDate": None
}
}
]
}
response = request_connect_store(url=url, data=data, headers=headers)
if response.status_code != 201:
julianajlk marked this conversation as resolved.
Show resolved Hide resolved
raise AppStoreRequestException("Couldn't apply price")

Check warning on line 227 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L225-L227

Added lines #L225 - L227 were not covered by tests


def upload_screenshot_of_inapp_purchase(in_app_purchase_id, headers):
""" Upload screenshot for the given product. """
url = APP_STORE_BASE_URL + "/v1/inAppPurchaseAppStoreReviewScreenshots"
data = {

Check warning on line 233 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L232-L233

Added lines #L232 - L233 were not covered by tests
"data": {
"type": "inAppPurchaseAppStoreReviewScreenshots",
"attributes": {
"fileName": "iOS_IAP.png",
"fileSize": 124790
},
"relationships": {
"inAppPurchaseV2": {
"data": {
"id": in_app_purchase_id,
"type": "inAppPurchases"
}
}
}
}
}

response = request_connect_store(url, headers, data=data)
if not response.status_code == 201:
Copy link
Contributor

Choose a reason for hiding this comment

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

I see sometimes you use not ... == and sometimes !=, for readability the later is easier

raise AppStoreRequestException("Couldn't get screenshot url")

Check warning on line 253 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L251-L253

Added lines #L251 - L253 were not covered by tests

response = response.json()
screenshot_id = response['data']['id']
url = response['data']['attributes']['uploadOperations'][0]['url']
with staticfiles_storage.open('images/mobile_ios_product_screenshot.png', 'rb') as image:
img_headers = headers.copy()
img_headers['Content-Type'] = 'image/png'
response = request_connect_store(url, headers=img_headers, data=image.read(), method='put')

Check warning on line 261 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L255-L261

Added lines #L255 - L261 were not covered by tests

if not response.status_code == 200:
raise AppStoreRequestException("Couldn't upload screenshot")

Check warning on line 264 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L263-L264

Added lines #L263 - L264 were not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add breif description in a comment or a docstring about what is happening with the screenshot image here from the staticfiles? Where does the screenshot images/mobile_ios_product_screenshot.png come from in the first place?


url = APP_STORE_BASE_URL + "/v1/inAppPurchaseAppStoreReviewScreenshots/{}".format(screenshot_id)
data = {

Check warning on line 267 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L266-L267

Added lines #L266 - L267 were not covered by tests
"data": {
"type": "inAppPurchaseAppStoreReviewScreenshots",
"id": screenshot_id,
"attributes": {
"uploaded": True,
"sourceFileChecksum": ""
}
}
}

response = request_connect_store(url, headers, data=data, method='patch')

Check warning on line 278 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L278

Added line #L278 was not covered by tests

if not response.status_code == 200:
raise AppStoreRequestException("Couldn't finalize screenshot")

Check warning on line 281 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L280-L281

Added lines #L280 - L281 were not covered by tests


def submit_in_app_purchase_for_review(in_app_purchase_id, headers):
""" Submit in app purchase for the final review by appstore. """
url = APP_STORE_BASE_URL + "/v1/inAppPurchaseSubmissions"
data = {

Check warning on line 287 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L286-L287

Added lines #L286 - L287 were not covered by tests
"data": {
"type": "inAppPurchaseSubmissions",
"relationships": {
"inAppPurchaseV2": {
"data": {
"type": "inAppPurchases",
"id": in_app_purchase_id
}
}
}
}
}
response = request_connect_store(url=url, data=data, headers=headers)
if not response.status_code == 201:
raise AppStoreRequestException("Couldn't submit purchase")

Check warning on line 302 in ecommerce/extensions/iap/api/v1/utils.py

View check run for this annotation

Codecov / codecov/patch

ecommerce/extensions/iap/api/v1/utils.py#L300-L302

Added lines #L300 - L302 were not covered by tests


class AppStoreRequestException(Exception):
pass
Loading
Loading