From 4fe1b77d34e762591f6047c02359629f2aedc8ac Mon Sep 17 00:00:00 2001 From: Jade Olivier Date: Thu, 15 Aug 2024 12:08:53 +0200 Subject: [PATCH] feat: Anonymizing CT customer data on LMS account deletion (#247) * feat: Anonymizing CT customer data on LMS account deletion * fix: sha1 changed to sha2 * fix: quality --- .../apps/commercetools/clients.py | 56 +++++++++++++ .../apps/commercetools/pipeline.py | 61 +++++++++++++- .../apps/commercetools/tests/conftest.py | 24 ++++++ .../apps/commercetools/tests/test_clients.py | 81 +++++++++++++++++++ .../apps/commercetools/tests/test_pipeline.py | 47 +++++++++++ .../apps/commercetools/tests/test_utils.py | 23 ++++++ .../apps/commercetools/utils.py | 39 +++++++++ commerce_coordinator/apps/lms/filters.py | 20 +++++ commerce_coordinator/apps/lms/serializers.py | 7 ++ .../apps/lms/tests/test_views.py | 78 ++++++++++++++++++ commerce_coordinator/apps/lms/urls.py | 8 +- commerce_coordinator/apps/lms/views.py | 79 +++++++++++++++++- commerce_coordinator/settings/base.py | 8 ++ 13 files changed, 527 insertions(+), 4 deletions(-) diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index 44012579..5a8e4863 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -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 @@ -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 diff --git a/commerce_coordinator/apps/commercetools/pipeline.py b/commerce_coordinator/apps/commercetools/pipeline.py index 6b762f80..518d54b9 100644 --- a/commerce_coordinator/apps/commercetools/pipeline.py +++ b/commerce_coordinator/apps/commercetools/pipeline.py @@ -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 @@ -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 ( @@ -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 diff --git a/commerce_coordinator/apps/commercetools/tests/conftest.py b/commerce_coordinator/apps/commercetools/tests/conftest.py index 45146722..c173843c 100644 --- a/commerce_coordinator/apps/commercetools/tests/conftest.py +++ b/commerce_coordinator/apps/commercetools/tests/conftest.py @@ -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(), diff --git a/commerce_coordinator/apps/commercetools/tests/test_clients.py b/commerce_coordinator/apps/commercetools/tests/test_clients.py index b5c730c9..8edbbe48 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_clients.py +++ b/commerce_coordinator/apps/commercetools/tests/test_clients.py @@ -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 @@ -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""" diff --git a/commerce_coordinator/apps/commercetools/tests/test_pipeline.py b/commerce_coordinator/apps/commercetools/tests/test_pipeline.py index 5fa71239..b506d962 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_pipeline.py +++ b/commerce_coordinator/apps/commercetools/tests/test_pipeline.py @@ -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, @@ -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 @@ -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) diff --git a/commerce_coordinator/apps/commercetools/tests/test_utils.py b/commerce_coordinator/apps/commercetools/tests/test_utils.py index fbf9b635..3b120ef8 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_utils.py +++ b/commerce_coordinator/apps/commercetools/tests/test_utils.py @@ -1,6 +1,7 @@ """ Tests for Commerce tools utils """ +import hashlib import unittest from unittest.mock import MagicMock @@ -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, @@ -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") diff --git a/commerce_coordinator/apps/commercetools/utils.py b/commerce_coordinator/apps/commercetools/utils.py index 695324d5..41c95282 100644 --- a/commerce_coordinator/apps/commercetools/utils.py +++ b/commerce_coordinator/apps/commercetools/utils.py @@ -2,6 +2,7 @@ Helpers for the commercetools app. """ +import hashlib import json import logging from urllib.parse import urljoin @@ -17,6 +18,9 @@ logger = logging.getLogger(__name__) +RETIRED_USER_FIELD_DEFAULT_FMT = 'retired_user_{}' +SALT_LIST_EXCEPTION = ValueError("Salt must be a list -or- tuple of all historical salts.") + def get_braze_client(): """ Returns a Braze client. """ @@ -254,3 +258,38 @@ def create_zendesk_ticket(requester_name, requester_email, subject, body, tags=N logger.exception(f'Failed to create ticket. Exception: {exc}') return False return True + + +def _create_retired_hash_withsalt(value_to_retire, salt): + """ + Returns a retired value given a value to retire and a hash. + + Arguments: + value_to_retire (str): Value to be retired. + salt (str): Salt string used to modify the retired value before hashing. + """ + return hashlib.sha256( + salt.encode() + value_to_retire.encode('utf-8') + ).hexdigest() + + +def create_retired_fields(field_value, salt_list, retired_user_field_fmt=RETIRED_USER_FIELD_DEFAULT_FMT): + """ + Returns a retired field value based on the original lowercased field value and + all the historical salts, from oldest to current. The current salt is + assumed to be the last salt in the list. + + Raises :class:`~ValueError` if the salt isn't a list of salts. + + Arguments: + field_value (str): The value of the field to be retired. + salt_list (list/tuple): List of all historical salts. + + Yields: + Returns a retired value based on the original field value + and all the historical salts, including the current salt. + """ + if not isinstance(salt_list, (list, tuple)): + raise SALT_LIST_EXCEPTION + + return retired_user_field_fmt.format(_create_retired_hash_withsalt(field_value.lower(), salt_list[-1])) diff --git a/commerce_coordinator/apps/lms/filters.py b/commerce_coordinator/apps/lms/filters.py index b88bb2be..f215f6a2 100644 --- a/commerce_coordinator/apps/lms/filters.py +++ b/commerce_coordinator/apps/lms/filters.py @@ -44,3 +44,23 @@ def run_filter(cls, order_id, order_line_item_id): # pragma no cover """ return super().run_pipeline(order_id=order_id, order_line_item_id=order_line_item_id) + + +class UserRetirementRequested(OpenEdxPublicFilter): + """ + Filter to anonymize retired customer fields in Commercetools + """ + # See pipeline step configuration OPEN_EDX_FILTERS_CONFIG dict in `settings/base.py` + filter_type = "org.edx.coordinator.lms.user.retirement.requested.v1" + + @classmethod + def run_filter(cls, lms_user_id): # pragma no cover + """ + Call the PipelineStep(s) defined for this filter. + Arguments: + lms_user_id: edx LMS user ID of customer + Returns: + returned_customer: Updated customer Commercetools object with anonymized fields + """ + + return super().run_pipeline(lms_user_id=lms_user_id) diff --git a/commerce_coordinator/apps/lms/serializers.py b/commerce_coordinator/apps/lms/serializers.py index 0d78cfb9..7b0d2b16 100644 --- a/commerce_coordinator/apps/lms/serializers.py +++ b/commerce_coordinator/apps/lms/serializers.py @@ -77,3 +77,10 @@ class CourseRefundInputSerializer(CoordinatorSerializer): def enrollment_attributes_dict(self) -> Dict[str, str]: """ Converts serializer data to a dict of {f"{namespace}.{name}": value, ... n} """ return dict([EnrollmentAttributeSerializer.dict_tuple(e) for e in self.data['enrollment_attributes']]) + + +class UserRetiredInputSerializer(CoordinatorSerializer): + """ + Serializer for User Deactivation/Retirement input validation + """ + edx_lms_user_id = serializers.IntegerField(allow_null=False) diff --git a/commerce_coordinator/apps/lms/tests/test_views.py b/commerce_coordinator/apps/lms/tests/test_views.py index f230aecb..a9672bd9 100644 --- a/commerce_coordinator/apps/lms/tests/test_views.py +++ b/commerce_coordinator/apps/lms/tests/test_views.py @@ -284,3 +284,81 @@ def test_post_with_invalid_attr_data_fails(self, drop_key): response = self.client.post(self.url, local_invalid_payload, format='json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +@ddt.ddt +class RetirementViewTests(APITestCase): + """ + Tests for user retirement view. + """ + + test_user_username = 'test' + test_user_email = 'test@example.com' + test_user_password = 'secret' + + url = reverse('lms:user_retirement') + + valid_payload = { + 'edx_lms_user_id': 127, + } + + invalid_payload = { + 'edx_lms_user_id': '', + } + + def setUp(self): + super().setUp() + self.user = User.objects.create_user( + self.test_user_username, + self.test_user_email, + self.test_user_password, + is_staff=True, + ) + + def tearDown(self): + super().tearDown() + self.client.logout() + + def authenticate_user(self): + self.client.login(username=self.test_user_username, password=self.test_user_password) + self.client.force_authenticate(user=self.user) + + @patch('commerce_coordinator.apps.lms.views.UserRetirementRequested.run_filter') + def test_post_with_valid_data_succeeds(self, mock_filter): + self.authenticate_user() + mock_filter.return_value = {'returned_customer': True} + response = self.client.post(self.url, self.valid_payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_filter.assert_called_once_with(127) + + @patch('commerce_coordinator.apps.lms.views.UserRetirementRequested.run_filter') + def test_post_with_valid_data_invalid_pipeline_return_fails(self, mock_filter): + self.authenticate_user() + mock_filter.return_value = {'returned_customer': None} + response = self.client.post(self.url, self.valid_payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + mock_filter.assert_called_once_with(127) + + def test_post_with_invalid_data_fails(self): + self.authenticate_user() + response = self.client.post(self.url, self.invalid_payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @patch('commerce_coordinator.apps.lms.views.UserRetirementRequested.run_filter') + def test_post_with_filter_exception_fails(self, mock_filter): + self.authenticate_user() + mock_filter.side_effect = OpenEdxFilterException('Filter failed') + response = self.client.post(self.url, self.valid_payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + @patch('commerce_coordinator.apps.lms.views.UserRetirementRequested.run_filter') + def test_post_with_unexpected_exception_fails(self, mock_filter): + self.authenticate_user() + mock_filter.side_effect = Exception('Unexpected error') + response = self.client.post(self.url, self.valid_payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/commerce_coordinator/apps/lms/urls.py b/commerce_coordinator/apps/lms/urls.py index d135560b..9ea99966 100644 --- a/commerce_coordinator/apps/lms/urls.py +++ b/commerce_coordinator/apps/lms/urls.py @@ -4,11 +4,17 @@ from django.urls import path -from commerce_coordinator.apps.lms.views import OrderDetailsRedirectView, PaymentPageRedirectView, RefundView +from commerce_coordinator.apps.lms.views import ( + OrderDetailsRedirectView, + PaymentPageRedirectView, + RefundView, + RetirementView +) app_name = 'lms' urlpatterns = [ path('payment_page_redirect/', PaymentPageRedirectView.as_view(), name='payment_page_redirect'), path('order_details_page_redirect/', OrderDetailsRedirectView.as_view(), name='order_details_page_redirect'), path('refund/', RefundView.as_view(), name='refund'), + path('user_retirement/', RetirementView.as_view(), name='user_retirement') ] diff --git a/commerce_coordinator/apps/lms/views.py b/commerce_coordinator/apps/lms/views.py index 2e4cb1c4..55403798 100644 --- a/commerce_coordinator/apps/lms/views.py +++ b/commerce_coordinator/apps/lms/views.py @@ -16,8 +16,16 @@ from rest_framework.views import APIView from commerce_coordinator.apps.core.constants import HttpHeadersNames, MediaTypes -from commerce_coordinator.apps.lms.filters import OrderRefundRequested, PaymentPageRedirectRequested -from commerce_coordinator.apps.lms.serializers import CourseRefundInputSerializer, enrollment_attribute_key +from commerce_coordinator.apps.lms.filters import ( + OrderRefundRequested, + PaymentPageRedirectRequested, + UserRetirementRequested +) +from commerce_coordinator.apps.lms.serializers import ( + CourseRefundInputSerializer, + UserRetiredInputSerializer, + enrollment_attribute_key +) from commerce_coordinator.apps.rollout.utils import is_legacy_order logger = logging.getLogger(__name__) @@ -247,3 +255,70 @@ def post(self, request) -> Response: except Exception as e: # pylint: disable=broad-except logger.exception(f"[RefundView] Exception raised in {self.post.__name__} with error {repr(e)}") return Response('Exception occurred while returning order', status=HTTP_500_INTERNAL_SERVER_ERROR) + + +class RetirementView(APIView): + """Accept incoming LMS request to retire user in CT.""" + permission_classes = [IsAdminUser] + throttle_classes = (UserRateThrottle,) + + def post(self, request) -> Response: + """ + Process a refund request from the LMS. + + Args: + request (Request): The HTTP request object containing the refund details. + + Returns: + - Response: + - 200 OK if the refund was successfully processed with the result of + the UserRetirementRequested filter/pipeline. + - 400 If the retirement request failed due to an invalid lms user uuid. + - 500 If an OpenEdxFilterException occurred while anonymizing the customer fields. + - 500 If any other unexpected exception occurred during retirement/anonymizing processing. + + The method expects a POST request with a JSON payload containing: + - lms_user_is (str): The ID of the lms user. + + The user retirement/field anonymization is processed by running the UserRetirementRequested + filter/pipeline using the provided lms_user_id from the request + + If the retirement is successfully marked in CT (the PII fields are successfully anonymized), a 200 OK + response is returned along with the result from the UserRetirementRequested filter/pipeline. + + If the retirement fails due to a bad pipeline response, a 400 Bad Request is returned. + + If an exception occurs during retirement processing, a 500 Internal Server Error is returned. + """ + input_data = {**request.data} + + input_details = UserRetiredInputSerializer(data=input_data) + try: + input_details.is_valid(raise_exception=True) + except ValidationError as e: + logger.exception(f"[RetirementView] Exception raised validating input {self.post.__name__} " + f"with error {repr(e)}, input: {input_data}.") + return Response('Invalid input provided', status=HTTP_400_BAD_REQUEST) + + lms_user_id = input_details.data['edx_lms_user_id'] + + try: + result = UserRetirementRequested.run_filter(lms_user_id) + + if result.get('returned_customer', None): + logger.info(f"[RetirementView] Successfully anonymized fields for retired customer with " + f"LMS ID {lms_user_id}, with result: {result}.") + return Response(status=HTTP_200_OK) + else: + logger.error(f"[RetirementView] Failed anonymizing fields for retired customer with " + f"LMS ID {lms_user_id}, with invalid filter/pipeline result: {result}.") + return Response('Exception occurred while returning order', status=HTTP_400_BAD_REQUEST) + + except OpenEdxFilterException as e: + logger.exception(f"[RetirementView] Exception raised in {self.post.__name__} with error {repr(e)}") + return Response('Exception occurred while retiring Commercetools customer', + status=HTTP_500_INTERNAL_SERVER_ERROR) + except Exception as e: # pylint: disable=broad-except + logger.exception(f"[RefundView] Exception raised in {self.post.__name__} with error {repr(e)}") + return Response('Exception occurred while retiring Commercetools customer', + status=HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/commerce_coordinator/settings/base.py b/commerce_coordinator/settings/base.py index a8e842f6..6d4d61f3 100644 --- a/commerce_coordinator/settings/base.py +++ b/commerce_coordinator/settings/base.py @@ -393,6 +393,12 @@ def root(*path_fragments): 'commerce_coordinator.apps.commercetools.pipeline.CreateReturnPaymentTransaction', 'commerce_coordinator.apps.commercetools.pipeline.UpdateCommercetoolsOrderReturnPaymentStatus', ] + }, + "org.edx.coordinator.lms.user.retirement.requested.v1": { + "fail_silently": False, # Coordinator filters should NEVER be allowed to fail silently + "pipeline": [ + 'commerce_coordinator.apps.commercetools.pipeline.AnonymizeRetiredUser', + ] } } @@ -431,6 +437,8 @@ def root(*path_fragments): LMS_DASHBOARD_URL = "http://localhost:18000" # fix me ORDER_HISTORY_URL = "http://localhost:1996" +RETIRED_USER_SALTS = ['abc', '123'] + _COMMERCETOOLS_CONFIG_GEO = 'us-central1.gcp' COMMERCETOOLS_CONFIG = {