Skip to content

Commit

Permalink
feat: Anonymizing CT customer data on LMS account deletion (#247)
Browse files Browse the repository at this point in the history
* feat: Anonymizing CT customer data on LMS account deletion

* fix: sha1 changed to sha2

* fix: quality
  • Loading branch information
JadeyOlivier authored Aug 15, 2024
1 parent 5fb5c9d commit 4fe1b77
Show file tree
Hide file tree
Showing 13 changed files with 527 additions and 4 deletions.
56 changes: 56 additions & 0 deletions commerce_coordinator/apps/commercetools/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
from commercetools import Client as CTClient
from commercetools import CommercetoolsError
from commercetools.platform.models import Customer as CTCustomer
from commercetools.platform.models import CustomerChangeEmailAction, CustomerSetCustomFieldAction
from commercetools.platform.models import CustomerSetCustomTypeAction as CTCustomerSetCustomTypeAction
from commercetools.platform.models import CustomerSetFirstNameAction, CustomerSetLastNameAction
from commercetools.platform.models import FieldContainer as CTFieldContainer
from commercetools.platform.models import Money as CTMoney
from commercetools.platform.models import Order as CTOrder
Expand Down Expand Up @@ -495,3 +497,57 @@ def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_
# Logs & ignores version conflict errors due to duplicate Commercetools messages
handle_commercetools_error(err, f"Unable to update LineItemState of order {order_id}")
return None

def retire_customer_anonymize_fields(self, customer_id: str, customer_version: int,
retired_first_name: str, retired_last_name: str,
retired_email: str, retired_lms_username: str) -> CTCustomer:
"""
Update Commercetools customer with anonymized fields
Args:
customer_id (str): Customer ID (UUID)
customer_version (int): Current version of customer
retired_first_name (str): anonymized customer first name value
retired_last_name (str): anonymized customer last name value
retired_email (str): anonymized customer email value
retired_lms_username (str): anonymized customer lms username value
Returns (CTCustomer): Updated customer object or
Raises Exception: Error if update was unsuccessful.
"""

actions = []
update_retired_first_name_action = CustomerSetFirstNameAction(
first_name=retired_first_name
)

update_retired_last_name_action = CustomerSetLastNameAction(
last_name=retired_last_name
)

update_retired_email_action = CustomerChangeEmailAction(
email=retired_email
)

update_retired_lms_username_action = CustomerSetCustomFieldAction(
name="edx-lms_user_name",
value=retired_lms_username
)

actions.extend([
update_retired_first_name_action,
update_retired_last_name_action,
update_retired_email_action,
update_retired_lms_username_action
])

try:
retired_customer = self.base_client.customers.update_by_id(
id=customer_id,
version=customer_version,
actions=actions
)
return retired_customer
except CommercetoolsError as err:
logger.error(f"[CommercetoolsError] Unable to anonymize customer fields for customer "
f"with ID: {customer_id}, after LMS retirement with "
f"error correlation id {err.correlation_id} and error/s: {err.errors}")
raise err
61 changes: 60 additions & 1 deletion commerce_coordinator/apps/commercetools/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import attrs
from commercetools import CommercetoolsError
from django.conf import settings
from openedx_filters import PipelineStep
from openedx_filters.exceptions import OpenEdxFilterException
from requests import HTTPError
Expand All @@ -17,7 +18,7 @@
from commerce_coordinator.apps.commercetools.clients import CommercetoolsAPIClient
from commerce_coordinator.apps.commercetools.constants import COMMERCETOOLS_ORDER_MANAGEMENT_SYSTEM
from commerce_coordinator.apps.commercetools.data import order_from_commercetools
from commerce_coordinator.apps.commercetools.utils import has_refund_transaction
from commerce_coordinator.apps.commercetools.utils import create_retired_fields, has_refund_transaction
from commerce_coordinator.apps.core.constants import PipelineCommand
from commerce_coordinator.apps.core.exceptions import InvalidFilterType
from commerce_coordinator.apps.rollout.utils import (
Expand Down Expand Up @@ -345,3 +346,61 @@ def run_filter(
f"payment_id: {payment_on_order.id}], message_id: {kwargs['message_id']}")
log.exception(f"[{tag}] HTTP Error: {err}")
return PipelineCommand.CONTINUE.value


class AnonymizeRetiredUser(PipelineStep):
"""
Finds a CT customer by their LMS user ID and anonymizes PII fields
following user retirement/account deletion in LMS
"""

def run_filter(
self,
lms_user_id
): # pylint: disable=arguments-differ
"""
Execute a filter with the signature specified.
Arguments:
lms_user_id: User UUID from LMS connecting to and
stored in CT customer object
Returns:
returned_customer: the modified CT customer
"""

tag = type(self).__name__

ct_api_client = CommercetoolsAPIClient()
try:
customer = ct_api_client.get_customer_by_lms_user_id(lms_user_id)
first_name = customer.first_name
last_name = customer.last_name
email = customer.email
lms_username = customer.custom.fields.get("edx-lms_user_name")
fields_to_anonymize = {
"first_name": first_name,
"last_name": last_name,
"email": email,
"lms_username": lms_username
}

anonymized_fields = {key: create_retired_fields(value, settings.RETIRED_USER_SALTS)
for key, value in fields_to_anonymize.items()}

retired_customer = ct_api_client.retire_customer_anonymize_fields(
customer.id,
customer.version,
anonymized_fields.get("first_name"),
anonymized_fields.get("last_name"),
anonymized_fields.get("email"),
anonymized_fields.get("lms_username")
)

return {
'returned_customer': retired_customer
}
except CommercetoolsError as err: # pragma no cover
log.exception(f"[{tag}] Commercetools Error: {err}, {err.errors}")
return PipelineCommand.CONTINUE.value
except HTTPError as err: # pragma no cover
log.exception(f"[{tag}] HTTP Error: {err}")
return PipelineCommand.CONTINUE.value
24 changes: 24 additions & 0 deletions commerce_coordinator/apps/commercetools/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,30 @@ def gen_customer(email: str, un: str):
)


def gen_retired_customer(first_name: str, last_name: str, email: str, un: str):
return CTCustomer(
email=email,
first_name=first_name,
last_name=last_name,
custom=CTCustomFields(
type=CTTypeReference(
id=uuid4_str()
),
fields=CTFieldContainer({
EdXFieldNames.LMS_USER_NAME: un,
EdXFieldNames.LMS_USER_ID: DEFAULT_EDX_LMS_USER_ID
})
),
version=3,
addresses=[],
authentication_mode=CTAuthenticationMode.PASSWORD,
created_at=datetime.now(),
id=uuid4_str(),
is_email_verified=True,
last_modified_at=datetime.now()
)


def gen_return_item(order_line_id: str, payment_state: ReturnPaymentState) -> CTLineItemReturnItem:
return CTLineItemReturnItem(
id=uuid4_str(),
Expand Down
81 changes: 81 additions & 0 deletions commerce_coordinator/apps/commercetools/tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
gen_order,
gen_order_history,
gen_payment,
gen_retired_customer,
gen_return_item
)
from commerce_coordinator.apps.core.constants import ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT
Expand Down Expand Up @@ -725,6 +726,86 @@ def test_order_line_item_in_correct_state(self, mock_order_by_id, mock_state_by_
self.assertEqual(result.id, mock_order.id)
self.assertEqual(result.version, mock_order.version)

def test_update_customer_with_anonymized_fields(self):
base_url = self.client_set.get_base_url_from_client()
mock_retired_first_name = "retired_user_b90b0331d08e19eaef586"
mock_retired_last_name = "retired_user_b45093f6f96eac6421f8"
mock_retired_email = "retired_user_149c01e31901998b11"
mock_retired_lms_username = "retired_user_8d2382cd8435a1c520"

mock_response_customer = gen_retired_customer(
mock_retired_first_name,
mock_retired_last_name,
mock_retired_email,
mock_retired_lms_username
)

with requests_mock.Mocker(real_http=True, case_sensitive=False) as mocker:
mocker.post(
f"{base_url}customers/{mock_response_customer.id}",
json=mock_response_customer.serialize(),
status_code=200
)

result = self.client_set.client.retire_customer_anonymize_fields(
mock_response_customer.id,
mock_response_customer.version,
mock_retired_first_name,
mock_retired_last_name,
mock_retired_email,
mock_retired_lms_username
)

self.assertEqual(result, mock_response_customer)

def test_update_customer_with_anonymized_fields_exception(self):
base_url = self.client_set.get_base_url_from_client()
mock_retired_first_name = "retired_user_b90b0331d08e19eaef586"
mock_retired_last_name = "retired_user_b45093f6f96eac6421f8"
mock_retired_email = "retired_user_149c01e31901998b11"
mock_retired_lms_username = "retired_user_8d2382cd8435a1c520"

mock_error_response: CommercetoolsError = {
"message": "Could not create return for order mock_order_id",
"errors": [
{
"code": "ConcurrentModification",
"detailedErrorMessage": "Object [mock_order_id] has a "
"different version than expected. Expected: 2 - Actual: 1."
},
],
"response": {},
"correlation_id": '123456'
}

with requests_mock.Mocker(real_http=True, case_sensitive=False) as mocker:
mocker.post(
f"{base_url}customers/mock_customer_id",
json=mock_error_response,
status_code=409
)

with patch('commerce_coordinator.apps.commercetools.clients.logging.Logger.error') as log_mock:
with self.assertRaises(CommercetoolsError) as cm:
self.client_set.client.retire_customer_anonymize_fields(
"mock_customer_id",
1,
mock_retired_first_name,
mock_retired_last_name,
mock_retired_email,
mock_retired_lms_username
)

exception = cm.exception

expected_message = (
f"[CommercetoolsError] Unable to anonymize customer fields for customer "
f"with ID: mock_customer_id, after LMS retirement with "
f"error correlation id {exception.correlation_id} and error/s: {exception.errors}"
)

log_mock.assert_called_once_with(expected_message)


class PaginatedResultsTest(TestCase):
"""Tests for the simple logic in our Paginated Results Class"""
Expand Down
47 changes: 47 additions & 0 deletions commerce_coordinator/apps/commercetools/tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from commerce_coordinator.apps.commercetools.constants import COMMERCETOOLS_ORDER_MANAGEMENT_SYSTEM
from commerce_coordinator.apps.commercetools.pipeline import (
AnonymizeRetiredUser,
CreateReturnForCommercetoolsOrder,
CreateReturnPaymentTransaction,
GetCommercetoolsOrders,
Expand All @@ -18,8 +19,10 @@
from commerce_coordinator.apps.commercetools.tests._test_cases import MonkeyPatchedGetOrderTestCase
from commerce_coordinator.apps.commercetools.tests.conftest import (
APITestingSet,
gen_customer,
gen_order,
gen_payment,
gen_retired_customer,
gen_return_item
)
from commerce_coordinator.apps.core.constants import ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT
Expand Down Expand Up @@ -213,3 +216,47 @@ def test_pipeline(self, mock_order_return_update):
result_data = ret['returned_order']
self.assertEqual(result_data, self.update_order_response)
self.assertEqual(result_data.return_info[1].items[0].payment_state, ReturnPaymentState.REFUNDED)


class AnonymizeRetiredUserPipelineTests(TestCase):
"""Commercetools pipeline testcase for CT customer retirement after account deletion in LMS"""
def setUp(self) -> None:
self.customer_data = gen_customer("mock_email", "mock_username")

mock_anonymized_first_name = "retired_user_b90b0331d08e19eaef586"
mock_anonymized_last_name = "retired_user_b45093f6f96eac6421f8"
mock_anonymized_email = "retired_user_149c01e31901998b11"
mock_anonymized_lms_username = "retired_user_8d2382cd8435a1c520"
self.mock_anonymize_result = {
"first_name": mock_anonymized_first_name,
"last_name": mock_anonymized_last_name,
"email": mock_anonymized_email,
"lms_username": mock_anonymized_lms_username
}
self.update_customer_response = gen_retired_customer(
mock_anonymized_first_name,
mock_anonymized_last_name,
mock_anonymized_email,
mock_anonymized_lms_username
)
self.mock_lms_user_id = 127

@patch(
'commerce_coordinator.apps.commercetools.clients.CommercetoolsAPIClient'
'.retire_customer_anonymize_fields'
)
@patch(
'commerce_coordinator.apps.commercetools.clients.CommercetoolsAPIClient'
'.get_customer_by_lms_user_id'
)
@patch('commerce_coordinator.apps.commercetools.pipeline.create_retired_fields')
def test_pipeline(self, mock_anonymize_fields, mock_customer_by_lms_id, mock_anonymized_customer_return):
"""Ensure pipeline is functioning as expected"""

pipe = AnonymizeRetiredUser("test_pipe", None)
mock_customer_by_lms_id.return_value = self.customer_data
mock_anonymize_fields.return_value = self.mock_anonymize_result
mock_anonymized_customer_return.return_value = self.update_customer_response
ret = pipe.run_filter(lms_user_id=self.mock_lms_user_id)
result_data = ret['returned_customer']
self.assertEqual(result_data, self.update_customer_response)
23 changes: 23 additions & 0 deletions commerce_coordinator/apps/commercetools/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Tests for Commerce tools utils
"""
import hashlib
import unittest
from unittest.mock import MagicMock

Expand All @@ -23,6 +24,7 @@
)
from commerce_coordinator.apps.commercetools.tests.constants import EXAMPLE_FULFILLMENT_SIGNAL_PAYLOAD
from commerce_coordinator.apps.commercetools.utils import (
create_retired_fields,
create_zendesk_ticket,
extract_ct_order_information_for_braze_canvas,
extract_ct_product_information_for_braze_canvas,
Expand Down Expand Up @@ -363,3 +365,24 @@ def test_create_zendesk_ticket_failed_response(self, mock_logger, mock_create_ze
tags
)
mock_logger.assert_called_once_with(f'Failed to create ticket. Exception: {exc.value}')


class TestRetirementAnonymizingTestCase(unittest.TestCase):
"""
Tests for anonymizing/hashing incomming field values
in Create Retired Fields Utils class
"""
def setUp(self):
self.field_value = "TestValue"
self.salt = "TestSalt"
self.salt_list = ["Salt1", "Salt2", self.salt]
self.expected_hash = hashlib.sha256((self.salt.encode() + self.field_value.lower().encode('utf-8'))).hexdigest()
self.expected_retired_field = f"retired_user_{self.expected_hash}"

def test_create_retired_fields(self):
result = create_retired_fields(self.field_value, self.salt_list)
self.assertEqual(result, self.expected_retired_field)

def test_create_retired_fields_with_invalid_salt_list(self):
with self.assertRaises(ValueError):
create_retired_fields(self.field_value, "invalid_salt_list")
Loading

0 comments on commit 4fe1b77

Please sign in to comment.