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

feat: Added data_share_consent field to order fullfillment notes #3939

Merged
Merged
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
9 changes: 9 additions & 0 deletions ecommerce/enterprise/tests/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,15 @@ def mock_consent_get(self, username, course_id, ec_uuid):
ec_uuid
)

def mock_consent_post(self, username, course_id, ec_uuid):
self.mock_consent_response(
username,
course_id,
ec_uuid,
method=responses.POST,
granted=True
)

def mock_consent_missing(self, username, course_id, ec_uuid):
self.mock_consent_response(
username,
Expand Down
18 changes: 18 additions & 0 deletions ecommerce/enterprise/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin
from ecommerce.enterprise.utils import (
CUSTOMER_CATALOGS_DEFAULT_RESPONSE,
create_enterprise_customer_user_consent,
enterprise_customer_user_needs_consent,
find_active_enterprise_customer_user,
get_enterprise_catalog,
Expand Down Expand Up @@ -144,6 +145,23 @@ def test_ecu_needs_consent(self):
self.mock_consent_not_required(**opts)
self.assertEqual(enterprise_customer_user_needs_consent(**kw), False)

@responses.activate
def test_ecu_create_consent(self):
opts = {
'ec_uuid': 'fake-uuid',
'course_id': 'course-v1:real+course+id',
'username': 'johnsmith',
}
kw = {
'enterprise_customer_uuid': 'fake-uuid',
'course_id': 'course-v1:real+course+id',
'username': 'johnsmith',
'site': self.site
}
self.mock_access_token_response()
self.mock_consent_post(**opts)
self.assertEqual(create_enterprise_customer_user_consent(**kw), True)

def test_get_enterprise_customer_uuid(self):
"""
Verify that enterprise customer UUID is returned for a voucher with an associated enterprise customer.
Expand Down
28 changes: 28 additions & 0 deletions ecommerce/enterprise/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,34 @@ def enterprise_customer_user_needs_consent(site, enterprise_customer_uuid, cours
return response.json()['consent_required']


def create_enterprise_customer_user_consent(site, enterprise_customer_uuid, course_id, username):
"""
Create a new consent for a particular username/EC UUID/course ID combination if one doesn't already exist.

Args:
site (Site): The site which is handling the consent-sensitive request
enterprise_customer_uuid (str): The UUID of the relevant EnterpriseCustomer
course_id (str): The ID of the relevant course for enrollment
username (str): The username of the user attempting to enroll into the course

Returns:
bool: consent recorded for the user specified by the username argument
for the EnterpriseCustomer specified by the enterprise_customer_uuid
argument and the course specified by the course_id argument.
"""
data = {
"username": username,
"enterprise_customer_uuid": enterprise_customer_uuid,
"course_id": course_id
}
api_client = site.siteconfiguration.oauth_api_client
consent_url = urljoin(f"{site.siteconfiguration.consent_api_url}/", "data_sharing_consent")

response = api_client.post(consent_url, json=data)
response.raise_for_status()
return response.json()['consent_provided']


def get_enterprise_customer_uuid_from_voucher(voucher):
"""
Given a Voucher, find the associated Enterprise Customer UUID, if it exists.
Expand Down
2 changes: 2 additions & 0 deletions ecommerce/extensions/executive_education_2u/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def place_free_order(
address,
user_details,
terms_accepted_at,
data_share_consent,
request=None,
): # pylint: disable=arguments-differ
"""
Expand Down Expand Up @@ -49,6 +50,7 @@ def place_free_order(
'address': address,
'user_details': user_details,
'terms_accepted_at': terms_accepted_at,
'data_share_consent': data_share_consent,
})

# Place an order. If order placement succeeds, the order is committed
Expand Down
1 change: 1 addition & 0 deletions ecommerce/extensions/executive_education_2u/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ class CheckoutActionSerializer(serializers.Serializer): # pylint: disable=abstr
address = AddressSerializer(required=False)
user_details = UserDetailsSerializer()
terms_accepted_at = serializers.CharField()
data_share_consent = serializers.BooleanField(required=False)
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def setUp(self):
'mobile_phone': '1234567890'
}
self.mock_terms_accepted_at = '2022-08-05T15:28:46.493Z',
self.mock_data_share_consent = True

# Ensure that the basket attribute type exists for these tests
self.basket_attribute_type, _ = BasketAttributeType.objects.get_or_create(
Expand All @@ -57,12 +58,14 @@ def test_order_note_created(self):
'address': self.mock_address,
'user_details': self.mock_user_details,
'terms_accepted_at': self.mock_terms_accepted_at,
'data_share_consent': self.mock_data_share_consent,
})
order = ExecutiveEducation2UOrderPlacementMixin().place_free_order(
basket,
self.mock_address,
self.mock_user_details,
self.mock_terms_accepted_at,
self.mock_data_share_consent,
)

self.assertEqual(basket.status, Basket.SUBMITTED)
Expand All @@ -77,4 +80,5 @@ def test_non_free_basket_order(self):
self.mock_address,
self.mock_user_details,
self.mock_terms_accepted_at,
self.mock_data_share_consent,
)
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ def _create_finish_checkout_payload(self, sku):
'mobile_phone': '1234567890'
},
'terms_accepted_at': '2022-08-05T15:28:46.493Z',
'data_share_consent': True,
}

def _create_basket(self, product, has_offer=False):
Expand Down
1 change: 1 addition & 0 deletions ecommerce/extensions/executive_education_2u/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ def finish_checkout(self, request):
address=request.data.get('address', {}),
user_details={**request.data['user_details'], 'email': request.user.email},
terms_accepted_at=request.data['terms_accepted_at'],
data_share_consent=request.data.get('data_share_consent', None),
request=request
)

Expand Down
35 changes: 32 additions & 3 deletions ecommerce/extensions/fulfillment/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@
)
from ecommerce.core.url_utils import get_lms_enrollment_api_url, get_lms_entitlement_api_url
from ecommerce.courses.models import Course
from ecommerce.courses.utils import mode_for_product
from ecommerce.courses.utils import get_course_info_from_catalog, mode_for_product
from ecommerce.enterprise.conditions import BasketAttributeType
from ecommerce.enterprise.mixins import EnterpriseDiscountMixin
from ecommerce.enterprise.utils import (
create_enterprise_customer_user_consent,
get_enterprise_customer_uuid_from_voucher,
get_or_create_enterprise_customer_user
)
Expand Down Expand Up @@ -949,8 +950,9 @@ def _create_enterprise_allocation_payload(
# This will be the offer that was applied. We will let an error be thrown if this doesn't exist.
discount = order.discounts.first()
enterprise_customer_uuid = str(discount.offer.condition.enterprise_customer_uuid)
data_share_consent = fulfillment_details.get('data_share_consent', None)

return {
payload = {
'payment_reference': order.number,
'enterprise_customer_uuid': enterprise_customer_uuid,
'currency': currency,
Expand All @@ -966,8 +968,30 @@ def _create_enterprise_allocation_payload(
],
**fulfillment_details.get('address', {}),
**fulfillment_details.get('user_details', {}),
'terms_accepted_at': fulfillment_details.get('terms_accepted_at', '')
'terms_accepted_at': fulfillment_details.get('terms_accepted_at', ''),
}
if data_share_consent:
payload['data_share_consent'] = data_share_consent
irfanuddinahmad marked this conversation as resolved.
Show resolved Hide resolved

return payload

def _create_enterprise_customer_user_consent(
self,
order,
line,
fulfillment_details
):
data_share_consent = fulfillment_details.get('data_share_consent', None)
course_info = get_course_info_from_catalog(order.site, line.product)
if data_share_consent and course_info:
discount = order.discounts.first()
enterprise_customer_uuid = str(discount.offer.condition.enterprise_customer_uuid)
create_enterprise_customer_user_consent(
site=order.site,
enterprise_customer_uuid=enterprise_customer_uuid,
course_id=course_info['key'],
username=order.user.username
)

def _get_fulfillment_details(self, order):
fulfillment_details_note = order.notes.filter(note_type='Fulfillment Details').first()
Expand Down Expand Up @@ -1020,6 +1044,11 @@ def fulfill_product(self, order, lines, email_opt_in=False):
)

try:
self._create_enterprise_customer_user_consent(
Copy link
Contributor

Choose a reason for hiding this comment

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

[clarification/curious] Say the create_enterprise_allocation API endpoint throws an exception below, after already successfully creating a new DSC record for the user/course here. It seems here appears we'd be keeping the newly created DSC record around in our system even though we failed to actually make the GEAG allocation/enrollment.

Curious on the rationale for creating the DSC record prior to a successful create_enterprise_allocation call to the GEAG API.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The assumption is that the consent api maybe more flaky than the GEAG api. Hence, if the consent fails to be recorded, we don't want the GEAG to be recorded as well.

Copy link
Contributor

@adamstankiewicz adamstankiewicz Apr 7, 2023

Choose a reason for hiding this comment

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

[curious] If we create a DSC record but then fail to create the GEAG enterprise allocation, could there exist a mechanism to attempt to remove the newly created DSC record (essentially garbage collect DSC record if allocation failed)? Either way, likely not something to invest too much time into given this code path will eventually be replaced by the APIs in the EMET system.

[aside] I'd also be curious to hear why you think the consent API might be more flaky than the GEAG API? 🤔

Copy link
Contributor

@muhammad-ammar muhammad-ammar Apr 7, 2023

Choose a reason for hiding this comment

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

Adam: I think issue can also happen if we first create the GEAG enrollment and then create consent record. Lets say consent errors out after successful GEAG enrollment. What to do in that case? Should we rollback the GEAG enrollment? I think consent is mandatory?

Copy link
Contributor

Choose a reason for hiding this comment

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

My understanding is that if both the GEAG enrollment and consent record should be present then we should rollback transaction in case of error in any of the GEAG enrollment call or consent record call?

Copy link
Contributor

Choose a reason for hiding this comment

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

Lets say consent errors out after successful GEAG enrollment. What to do in that case? Should we rollback the GEAG enrollment? I think consent is mandatory?

FWIW, I'm just clarifying the approach. I don't feel strongly that the DSC call needs to be after a successful GEAG allocation. Just asking for my own edification 😄

That said, when we call GEAG, we are already passing data_share_consent: true in the GEAG API payload, which from my understanding should be sufficient from GEAG's perspective. I had thought our LPR reporting will be relying on the data_share_consent: true passed to the GEAG enterprise allocation API, not so much an actual DSC record in our own database (this assumption may be incorrect).

Also, isn't consent conditionally required based on the enable_data_sharing_consent config flag on the EnterpriseCustomer?

image

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @adamstankiewicz ... always much appreciate your insights. I think we did not want to change the LPR pipeline with flags from GEAG. Hence, this consent api call ... you are correct that DSC should be garbage collected at some point. The frontend MFE will ensure that the consent checkbox is only visible for those learners who have the enable_data_sharing_consent config flag on their EnterpriseCustomer.

Copy link
Contributor Author

@irfanuddinahmad irfanuddinahmad Apr 7, 2023

Choose a reason for hiding this comment

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

@johnnagro @iloveagent57 I am not to familiar with the plan for GEAG<->DSC integration for LPR ... Can you review also?
We are currently creating the DSC records to enable LPR for these learners. However, api failure could result in a valid DSC without an associated allocation in GEAG. However, the DSC calls are idempotent ... so we expect to have the situation fixed whenever the allocation attempt is repeated.

order=order,
line=line,
fulfillment_details=fulfillment_details
)
self.get_smarter_client.create_enterprise_allocation(**allocation_payload)
except Exception as ex: # pylint: disable=broad-except
reason = ''
Expand Down
27 changes: 25 additions & 2 deletions ecommerce/extensions/fulfillment/tests/test_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,7 @@ def setUp(self):
'mobile_phone': '+12015551234',
},
'terms_accepted_at': '2022-07-25T10:29:56Z',
'data_share_consent': True
})

self.mock_settings = {
Expand Down Expand Up @@ -1304,37 +1305,59 @@ def test_fulfill_product_maformed_fulfillment_details(self, mock_logger):
self.exec_ed_2u_entitlement_line.order.number
)

@mock.patch('ecommerce.extensions.fulfillment.modules.create_enterprise_customer_user_consent')
@mock.patch('ecommerce.extensions.fulfillment.modules.get_course_info_from_catalog')
@mock.patch('ecommerce.extensions.fulfillment.modules.GetSmarterEnterpriseApiClient')
def test_fulfill_product_success(self, mock_geag_client):
def test_fulfill_product_success(
self,
mock_geag_client,
mock_get_course_info_from_catalog,
mock_create_enterprise_customer_user_consent
):
with self.settings(**self.mock_settings):
mock_create_enterprise_allocation = mock.MagicMock()
mock_geag_client.return_value = mock.MagicMock(
create_enterprise_allocation=mock_create_enterprise_allocation
)
mock_get_course_info_from_catalog.return_value = {
'key': 'test_course_key1'
}
self.order.notes.create(message=self.fulfillment_details, note_type='Fulfillment Details')
ExecutiveEducation2UFulfillmentModule().fulfill_product(
self.order,
[self.exec_ed_2u_entitlement_line, self.exec_ed_2u_entitlement_line_2]
)
self.assertEqual(mock_create_enterprise_allocation.call_count, 2)
self.assertEqual(mock_create_enterprise_customer_user_consent.call_count, 2)
self.assertEqual(self.exec_ed_2u_entitlement_line.status, LINE.COMPLETE)
self.assertEqual(self.exec_ed_2u_entitlement_line_2.status, LINE.COMPLETE)
self.assertFalse(self.order.notes.exists())

@mock.patch('ecommerce.extensions.fulfillment.modules.create_enterprise_customer_user_consent')
@mock.patch('ecommerce.extensions.fulfillment.modules.get_course_info_from_catalog')
@mock.patch('ecommerce.extensions.fulfillment.modules.GetSmarterEnterpriseApiClient')
def test_fulfill_product_error(self, mock_geag_client):
def test_fulfill_product_error(
self,
mock_geag_client,
mock_get_course_info_from_catalog,
mock_create_enterprise_customer_user_consent
):
with self.settings(**self.mock_settings):
mock_create_enterprise_allocation = mock.MagicMock()
mock_create_enterprise_allocation.side_effect = [None, Exception("Uh oh.")]
mock_geag_client.return_value = mock.MagicMock(
create_enterprise_allocation=mock_create_enterprise_allocation
)
mock_get_course_info_from_catalog.return_value = {
'key': 'test_course_key1'
}
self.order.notes.create(message=self.fulfillment_details, note_type='Fulfillment Details')
ExecutiveEducation2UFulfillmentModule().fulfill_product(
self.order,
[self.exec_ed_2u_entitlement_line, self.exec_ed_2u_entitlement_line_2]
)
self.assertEqual(mock_create_enterprise_allocation.call_count, 2)
self.assertEqual(mock_create_enterprise_customer_user_consent.call_count, 2)
self.assertEqual(self.exec_ed_2u_entitlement_line.status, LINE.COMPLETE)
self.assertEqual(self.exec_ed_2u_entitlement_line_2.status, LINE.FULFILLMENT_SERVER_ERROR)
self.assertTrue(self.order.notes.exists())
Expand Down
2 changes: 1 addition & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ funcsigs==1.0.2
# via cybersource-rest-client-python
future==0.18.2
# via pyjwkest
getsmarter-api-clients==0.4.0
getsmarter-api-clients==0.5.0
# via -r requirements/base.in
google-api-core==1.30.0
# via google-api-python-client
Expand Down
2 changes: 1 addition & 1 deletion requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ future==0.18.2
# via
# -r requirements/test.txt
# pyjwkest
getsmarter-api-clients==0.4.0
getsmarter-api-clients==0.5.0
# via -r requirements/test.txt
gitdb==4.0.9
# via gitpython
Expand Down
2 changes: 1 addition & 1 deletion requirements/production.txt
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ future==0.18.2
# via
# django-ses
# pyjwkest
getsmarter-api-clients==0.4.0
getsmarter-api-clients==0.5.0
# via -r requirements/base.in
# via
# django-ses
Expand Down
2 changes: 1 addition & 1 deletion requirements/test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ future==0.18.2
# via
# -r requirements/base.txt
# pyjwkest
getsmarter-api-clients==0.4.0
getsmarter-api-clients==0.5.0
# via -r requirements/base.txt
google-api-core==1.30.0
# via
Expand Down