diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index 1d2ec44c..eccdaec2 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -6,16 +6,23 @@ import decimal import logging from types import SimpleNamespace -from typing import Generic, List, Optional, Tuple, TypeVar, Union +from typing import Generic, List, Optional, Tuple, TypeVar, TypedDict, Union import requests -import stripe 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 ( + 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 @@ -23,10 +30,13 @@ OrderAddReturnInfoAction, OrderSetReturnItemCustomTypeAction, OrderSetReturnPaymentStateAction, - OrderTransitionLineItemStateAction + OrderTransitionLineItemStateAction, ) from commercetools.platform.models import Payment as CTPayment -from commercetools.platform.models import PaymentAddTransactionAction, PaymentSetTransactionCustomTypeAction +from commercetools.platform.models import ( + PaymentAddTransactionAction, + PaymentSetTransactionCustomTypeAction, +) from commercetools.platform.models import ProductVariant as CTProductVariant from commercetools.platform.models import ( ReturnItemDraft, @@ -34,24 +44,36 @@ ReturnShipmentState, StateResourceIdentifier, TransactionDraft, - TransactionType + TransactionType, ) from commercetools.platform.models import Type as CTType from commercetools.platform.models import TypeDraft as CTTypeDraft -from commercetools.platform.models import TypeResourceIdentifier as CTTypeResourceIdentifier +from commercetools.platform.models import ( + TypeResourceIdentifier as CTTypeResourceIdentifier, +) from commercetools.platform.models.state import State as CTLineItemState from django.conf import settings from openedx_filters.exceptions import OpenEdxFilterException -from commerce_coordinator.apps.commercetools.catalog_info.constants import DEFAULT_ORDER_EXPANSION, EdXFieldNames -from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import TwoUCustomTypes +from commerce_coordinator.apps.commercetools.catalog_info.constants import ( + DEFAULT_ORDER_EXPANSION, + EdXFieldNames, +) +from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import ( + TwoUCustomTypes, +) from commerce_coordinator.apps.commercetools.utils import ( find_refund_transaction, handle_commercetools_error, - translate_refund_status_to_transaction_status + translate_refund_status_to_transaction_status, ) from commerce_coordinator.apps.core.constants import ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT +from commerce_coordinator.apps.commercetools.catalog_info.constants import ( + EDX_STRIPE_PAYMENT_INTERFACE_NAME, + EDX_PAYPAL_PAYMENT_INTERFACE_NAME +) + logger = logging.getLogger(__name__) T = TypeVar("T") @@ -60,7 +82,8 @@ class PaginatedResult(Generic[T]): - """ Planned paginated response wrapper """ + """Planned paginated response wrapper""" + results: List[T] total: int offset: int @@ -83,9 +106,19 @@ def __getitem__(self, item): def rebuild(self, results: List[T]): return PaginatedResult(results, total=self.total, offset=self.offset) +class Refund(TypedDict): + """ + Refund object definition + """ + id: str + amount: Union[str, int] + currency: str + created: Union[str, int] + status: str class CommercetoolsAPIClient: - """ Commercetools API Client """ + """Commercetools API Client""" + base_client = None def __init__(self): @@ -101,10 +134,10 @@ def __init__(self): self.base_client = CTClient( client_id=config["clientId"], client_secret=config["clientSecret"], - scope=config["scopes"].split(' '), + scope=config["scopes"].split(" "), url=config["apiUrl"], token_url=config["authUrl"], - project_key=config["projectKey"] + project_key=config["projectKey"], ) def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: @@ -124,7 +157,9 @@ def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: except CommercetoolsError as _: # pragma: no cover # commercetools.exceptions.CommercetoolsError: The Resource with key 'edx-user_information' was not found. pass - except requests.exceptions.HTTPError as _: # The test framework doesn't wrap to CommercetoolsError + except ( + requests.exceptions.HTTPError + ) as _: # The test framework doesn't wrap to CommercetoolsError pass if not type_exists: @@ -132,7 +167,9 @@ def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: return type_object - def tag_customer_with_lms_user_info(self, customer: CTCustomer, lms_user_id: int, lms_user_name: str) -> CTCustomer: + def tag_customer_with_lms_user_info( + self, customer: CTCustomer, lms_user_id: int, lms_user_name: str + ) -> CTCustomer: """ Updates a CoCo Customer Object with what we are currently using for LMS Identifiers Args: @@ -146,28 +183,38 @@ def tag_customer_with_lms_user_info(self, customer: CTCustomer, lms_user_id: int # All updates to CT Core require the version of the object you are working on as protection from out of band # updates; this does mean we have to fetch every (primary) object we want to chain. - type_object = self.ensure_custom_type_exists(TwoUCustomTypes.CUSTOMER_TYPE_DRAFT) + type_object = self.ensure_custom_type_exists( + TwoUCustomTypes.CUSTOMER_TYPE_DRAFT + ) # A customer can only have one custom type associated to it, and thus only one set of custom fields. THUS... # They can't be required, and shouldn't entirely be relied upon; Once a proper Type is changed, the old values # are LOST. if customer.custom and not customer.custom.type.id == type_object.id: - raise ValueError("User already has a custom type, and its not the one were expecting, Refusing to update. " - "(Updating will eradicate the values from the other type, as an object may only have one " - "Custom Type)") + raise ValueError( + "User already has a custom type, and its not the one were expecting, Refusing to update. " + "(Updating will eradicate the values from the other type, as an object may only have one " + "Custom Type)" + ) - ret = self.base_client.customers.update_by_id(customer.id, customer.version, actions=[ - CTCustomerSetCustomTypeAction( - type=CTTypeResourceIdentifier( - key=TwoUCustomTypes.CUSTOMER_TYPE_DRAFT.key, + ret = self.base_client.customers.update_by_id( + customer.id, + customer.version, + actions=[ + CTCustomerSetCustomTypeAction( + type=CTTypeResourceIdentifier( + key=TwoUCustomTypes.CUSTOMER_TYPE_DRAFT.key, + ), + fields=CTFieldContainer( + { + EdXFieldNames.LMS_USER_ID: f"{lms_user_id}", + EdXFieldNames.LMS_USER_NAME: lms_user_name, + } + ), ), - fields=CTFieldContainer({ - EdXFieldNames.LMS_USER_ID: f"{lms_user_id}", - EdXFieldNames.LMS_USER_NAME: lms_user_name - }) - ), - ]) + ], + ) return ret @@ -183,15 +230,17 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: is returned. """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to get customer with LMS user id: {lms_user_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to get customer with LMS user id: {lms_user_id}" + ) edx_lms_user_id_key = EdXFieldNames.LMS_USER_ID start_time = datetime.datetime.now() results = self.base_client.customers.query( - where=f'custom(fields({edx_lms_user_id_key}=:id))', + where=f"custom(fields({edx_lms_user_id_key}=:id))", limit=2, - predicate_var={'id': f"{lms_user_id}"} + predicate_var={"id": f"{lms_user_id}"}, ) duration = (datetime.datetime.now() - start_time).total_seconds() logger.info(f"[Performance Check] - customerId query took {duration} seconds") @@ -199,20 +248,29 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: if results.count > 1: # We are unable due to CT Limitations to enforce unique LMS ID values on Customers on the catalog side, so # let's do a backhanded check by trying to pull 2 users and erroring if we find a discrepancy. - logger.info(f"[CommercetoolsAPIClient] - More than one customer found with LMS " - f"user id: {lms_user_id}, raising error") - raise ValueError("More than one user was returned from the catalog with this edX LMS User ID, these must " - "be unique.") + logger.info( + f"[CommercetoolsAPIClient] - More than one customer found with LMS " + f"user id: {lms_user_id}, raising error" + ) + raise ValueError( + "More than one user was returned from the catalog with this edX LMS User ID, these must " + "be unique." + ) if results.count == 0: - logger.info(f"[CommercetoolsAPIClient] - No customer found with LMS user id: {lms_user_id}") + logger.info( + f"[CommercetoolsAPIClient] - No customer found with LMS user id: {lms_user_id}" + ) return None else: - logger.info(f"[CommercetoolsAPIClient] - Customer found with LMS user id: {lms_user_id}") + logger.info( + f"[CommercetoolsAPIClient] - Customer found with LMS user id: {lms_user_id}" + ) return results.results[0] - def get_order_by_id(self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION) \ - -> CTOrder: + def get_order_by_id( + self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION + ) -> CTOrder: """ Fetch an order by the Order ID (UUID) @@ -222,11 +280,14 @@ def get_order_by_id(self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPA Returns (CTOrder): Order with Expanded Properties """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to find order with id: {order_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find order with id: {order_id}" + ) return self.base_client.orders.get_by_id(order_id, expand=list(expand)) - def get_order_by_number(self, order_number: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION) \ - -> CTOrder: + def get_order_by_number( + self, order_number: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION + ) -> CTOrder: """ Fetch an order by the Order Number (Human readable order number) @@ -236,14 +297,21 @@ def get_order_by_number(self, order_number: str, expand: ExpandList = DEFAULT_OR Returns (CTOrder): Order with Expanded Properties """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to find order with number {order_number}") - return self.base_client.orders.get_by_order_number(order_number, expand=list(expand)) - - def get_orders(self, customer_id: str, offset=0, - limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, - expand: ExpandList = DEFAULT_ORDER_EXPANSION, - order_state="Complete") -> PaginatedResult[CTOrder]: + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find order with number {order_number}" + ) + return self.base_client.orders.get_by_order_number( + order_number, expand=list(expand) + ) + def get_orders( + self, + customer_id: str, + offset=0, + limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, + expand: ExpandList = DEFAULT_ORDER_EXPANSION, + order_state="Complete", + ) -> PaginatedResult[CTOrder]: """ Call commercetools API overview endpoint for data about historical orders. @@ -259,27 +327,35 @@ def get_orders(self, customer_id: str, offset=0, See sample response in tests.py """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to find all completed orders for " - f"customer with ID {customer_id}") - order_where_clause = f"orderState=\"{order_state}\"" + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find all completed orders for " + f"customer with ID {customer_id}" + ) + order_where_clause = f'orderState="{order_state}"' start_time = datetime.datetime.now() values = self.base_client.orders.query( where=["customerId=:cid", order_where_clause], - predicate_var={'cid': customer_id}, + predicate_var={"cid": customer_id}, sort=["completedAt desc", "lastModifiedAt desc"], limit=limit, offset=offset, - expand=list(expand) + expand=list(expand), ) duration = (datetime.datetime.now() - start_time).total_seconds() logger.info(f"[Performance Check] get_orders call took {duration} seconds") return PaginatedResult(values.results, values.total, values.offset) - def get_orders_for_customer(self, edx_lms_user_id: int, offset=0, limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, - customer_id=None, email=None, - username=None) -> (PaginatedResult[CTOrder], CTCustomer): + def get_orders_for_customer( + self, + edx_lms_user_id: int, + offset=0, + limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, + customer_id=None, + email=None, + username=None, + ) -> (PaginatedResult[CTOrder], CTCustomer): """ Args: @@ -291,21 +367,21 @@ def get_orders_for_customer(self, edx_lms_user_id: int, offset=0, limit=ORDER_HI customer = self.get_customer_by_lms_user_id(edx_lms_user_id) if customer is None: # pragma: no cover - raise ValueError(f'Unable to locate customer with ID #{edx_lms_user_id}') + raise ValueError( + f"Unable to locate customer with ID #{edx_lms_user_id}" + ) customer_id = customer.id else: if email is None or username is None: # pragma: no cover - raise ValueError("If customer_id is provided, both email and username must be provided") + raise ValueError( + "If customer_id is provided, both email and username must be provided" + ) customer = SimpleNamespace( id=customer_id, email=email, - custom=SimpleNamespace( - fields={ - EdXFieldNames.LMS_USER_NAME: username - } - ) + custom=SimpleNamespace(fields={EdXFieldNames.LMS_USER_NAME: username}), ) orders = self.get_orders(customer_id, offset, limit) @@ -313,58 +389,83 @@ def get_orders_for_customer(self, edx_lms_user_id: int, offset=0, limit=ORDER_HI return orders, customer def get_customer_by_id(self, customer_id: str) -> CTCustomer: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find customer with ID {customer_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find customer with ID {customer_id}" + ) return self.base_client.customers.get_by_id(customer_id) def get_state_by_id(self, state_id: str) -> CTLineItemState: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find state with id {state_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find state with id {state_id}" + ) return self.base_client.states.get_by_id(state_id) def get_state_by_key(self, state_key: str) -> CTLineItemState: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find state with key {state_key}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find state with key {state_key}" + ) return self.base_client.states.get_by_key(state_key) def get_payment_by_key(self, payment_key: str) -> CTPayment: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find payment with key {payment_key}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find payment with key {payment_key}" + ) return self.base_client.payments.get_by_key(payment_key) - def get_product_variant_by_course_run(self, cr_id: str) -> Optional[CTProductVariant]: + def get_payment_by_transaction_interaction_id( + self, interaction_id: str + ) -> CTPayment: + """ + Fetch a payment by the transaction interaction ID + """ + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find payment with interaction ID {interaction_id}" + ) + return self.base_client.payments.query( + where=f'transactions(interactionId="{interaction_id}")' + ).results[0] + + def get_product_variant_by_course_run( + self, cr_id: str + ) -> Optional[CTProductVariant]: """ Args: cr_id: variant course run key """ start_time = datetime.datetime.now() - results = self.base_client.product_projections.search(False, filter=f"variants.sku:\"{cr_id}\"").results + results = self.base_client.product_projections.search( + False, filter=f'variants.sku:"{cr_id}"' + ).results duration = (datetime.datetime.now() - start_time).total_seconds() logger.info( - f"[Performance Check] get_product_variant_by_course_run took {duration} seconds") + f"[Performance Check] get_product_variant_by_course_run took {duration} seconds" + ) if len(results) < 1: # pragma no cover return None # Make 2D List of all variants from all results, and then flatten - all_variants = [listitem for sublist in - list( - map( - lambda selection: [selection.master_variant, *selection.variants], - results - ) - ) - for listitem in sublist] - - matching_variant_list = list( - filter( - lambda v: v.sku == cr_id, - all_variants + all_variants = [ + listitem + for sublist in list( + map( + lambda selection: [selection.master_variant, *selection.variants], + results, + ) ) - ) + for listitem in sublist + ] + + matching_variant_list = list(filter(lambda v: v.sku == cr_id, all_variants)) if len(matching_variant_list) < 1: # pragma no cover return None return matching_variant_list[0] - def create_return_for_order(self, order_id: str, order_version: int, order_line_item_id: str) -> CTOrder: + def create_return_for_order( + self, order_id: str, order_version: int, order_line_item_id: str + ) -> CTOrder: """ Creates refund/return for Commercetools order Args: @@ -376,8 +477,10 @@ def create_return_for_order(self, order_id: str, order_version: int, order_line_ """ try: - return_item_draft_comment = f'Creating return item for order {order_id} with ' \ - f'order line item ID {order_line_item_id}' + return_item_draft_comment = ( + f"Creating return item for order {order_id} with " + f"order line item ID {order_line_item_id}" + ) logger.info(f"[CommercetoolsAPIClient] - {return_item_draft_comment}") @@ -388,25 +491,26 @@ def create_return_for_order(self, order_id: str, order_version: int, order_line_ shipment_state=ReturnShipmentState.RETURNED, ) - add_return_info_action = OrderAddReturnInfoAction( - items=[return_item_draft] - ) + add_return_info_action = OrderAddReturnInfoAction(items=[return_item_draft]) returned_order = self.base_client.orders.update_by_id( - id=order_id, - version=order_version, - actions=[add_return_info_action] + id=order_id, version=order_version, actions=[add_return_info_action] ) return returned_order except CommercetoolsError as err: - handle_commercetools_error(err, f"Unable to create return for order {order_id}") + handle_commercetools_error( + err, f"Unable to create return for order {order_id}" + ) raise err - def update_return_payment_state_after_successful_refund(self, order_id: str, - order_version: int, - return_line_item_return_id: str, - payment_intent_id: str, - amount_in_cents: decimal) -> Union[CTOrder, None]: + def update_return_payment_state_after_successful_refund( + self, + order_id: str, + order_version: int, + return_line_item_return_id: str, + payment_intent_id: str, + amount_in_cents: decimal, + ) -> Union[CTOrder, None]: """ Update paymentState on the LineItemReturnItem attached to the order. Updated by the Order ID (UUID) @@ -420,80 +524,110 @@ def update_return_payment_state_after_successful_refund(self, order_id: str, Raises Exception: Error if update was unsuccessful. """ try: - logger.info(f"[CommercetoolsAPIClient] - Updating payment state for return " - f"with id {return_line_item_return_id} to '{ReturnPaymentState.REFUNDED}'.") + logger.info( + f"[CommercetoolsAPIClient] - Updating payment state for return " + f"with id {return_line_item_return_id} to '{ReturnPaymentState.REFUNDED}'." + ) return_payment_state_action = OrderSetReturnPaymentStateAction( return_item_id=return_line_item_return_id, - payment_state=ReturnPaymentState.REFUNDED + payment_state=ReturnPaymentState.REFUNDED, ) if not payment_intent_id: - payment_intent_id = '' - logger.info(f'Creating return for order - payment_intent_id: {payment_intent_id}') + payment_intent_id = "" + logger.info( + f"Creating return for order - payment_intent_id: {payment_intent_id}" + ) payment = self.get_payment_by_key(payment_intent_id) logger.info(f"Payment found: {payment}") transaction_id = find_refund_transaction(payment, amount_in_cents) update_transaction_id_action = OrderSetReturnItemCustomTypeAction( return_item_id=return_line_item_return_id, type=CTTypeResourceIdentifier( - key='returnItemCustomType', + key="returnItemCustomType", ), - fields=CTFieldContainer({ - 'transactionId': transaction_id - }) + fields=CTFieldContainer({"transactionId": transaction_id}), + ) + return_transaction_return_item_action = ( + PaymentSetTransactionCustomTypeAction( + transaction_id=transaction_id, + type=CTTypeResourceIdentifier(key="transactionCustomType"), + fields=CTFieldContainer( + {"returnItemId": return_line_item_return_id} + ), + ) ) - return_transaction_return_item_action = PaymentSetTransactionCustomTypeAction( - transaction_id=transaction_id, - type=CTTypeResourceIdentifier(key='transactionCustomType'), - fields=CTFieldContainer({ - 'returnItemId': return_line_item_return_id - }) + logger.info( + f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}" ) - logger.info(f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}") updated_order = self.base_client.orders.update_by_id( id=order_id, version=order_version, - actions=[return_payment_state_action, update_transaction_id_action] + actions=[return_payment_state_action, update_transaction_id_action], ) self.base_client.payments.update_by_id( id=payment.id, version=payment.version, - actions=[return_transaction_return_item_action] + actions=[return_transaction_return_item_action], ) logger.info("Updated transaction with return item id") return updated_order except CommercetoolsError as err: - handle_commercetools_error(err, f"Unable to update ReturnPaymentState of order {order_id}") + handle_commercetools_error( + err, f"Unable to update ReturnPaymentState of order {order_id}" + ) raise OpenEdxFilterException(str(err)) from err + def _preprocess_refund_object(self, refund: Refund, psp:str) -> Refund: + """ + Pre process refund object based on PSP + """ + if psp == EDX_PAYPAL_PAYMENT_INTERFACE_NAME: + refund["amount"] = float(refund["amount"]) * 100 + refund["created"] = datetime.datetime.fromisoformat(refund["created"]) + else: + refund["created"] = datetime.datetime.utcfromtimestamp(refund["created"]) + + refund["status"] = translate_refund_status_to_transaction_status(refund["status"]) + refund["currency"] = refund["currency"].upper() + return refund + def create_return_payment_transaction( - self, payment_id: str, - payment_version: int, - stripe_refund: stripe.Refund) -> CTPayment: + self, + payment_id: str, + payment_version: int, + refund: Refund, + psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME + ) -> CTPayment: """ Create Commercetools payment transaction for refund Args: payment_id (str): Payment ID (UUID) payment_version (int): Current version of payment - stripe_refund (stripe.Refund): Stripe's refund object + refund (stripe.Refund): Refund object Returns (CTPayment): Updated payment object or Raises Exception: Error if creation was unsuccessful. """ try: - logger.info(f"[CommercetoolsAPIClient] - Creating refund transaction for payment with ID {payment_id} " - f"following successful Stripe refund {stripe_refund.id}") + logger.info( + f"[CommercetoolsAPIClient] - Creating refund transaction for payment with ID {payment_id} " + f"following successful refund {refund['id']}" + ) + refund = self._preprocess_refund_object(refund, psp) amount_as_money = CTMoney( - cent_amount=stripe_refund.amount, - currency_code=stripe_refund.currency.upper() + cent_amount=float( + refund["amount"] + ), + currency_code=refund["currency"], ) transaction_draft = TransactionDraft( type=TransactionType.REFUND, amount=amount_as_money, - timestamp=datetime.datetime.utcfromtimestamp(stripe_refund.created), - state=translate_refund_status_to_transaction_status(stripe_refund.status), - interaction_id=stripe_refund.id + timestamp=refund["created"], + state=refund["status"], + interaction_id=refund["id"], ) add_transaction_action = PaymentAddTransactionAction( @@ -501,21 +635,27 @@ def create_return_payment_transaction( ) returned_payment = self.base_client.payments.update_by_id( - id=payment_id, - version=payment_version, - actions=[add_transaction_action] + id=payment_id, version=payment_version, actions=[add_transaction_action] ) return returned_payment except CommercetoolsError as err: - context = f"Unable to create refund payment transaction for "\ - f"payment {payment_id} and stripe refund {stripe_refund.id}" + context = ( + f"Unable to create refund payment transaction for " + f"payment {payment_id} and refund {refund['id']}" + ) handle_commercetools_error(err, context) raise err - def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_version: int, - line_item_id: str, item_quantity: int, - from_state_id: str, new_state_key: str) -> CTOrder: + def update_line_item_transition_state_on_fulfillment( + self, + order_id: str, + order_version: int, + line_item_id: str, + item_quantity: int, + from_state_id: str, + new_state_key: str, + ) -> CTOrder: """ Update Commercetools order line item state Args: @@ -532,8 +672,10 @@ def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_ from_state_key = self.get_state_by_id(from_state_id).key - logger.info(f"[CommercetoolsAPIClient] - Transitioning line item state for order with ID {order_id}" - f"from {from_state_key} to {new_state_key}") + logger.info( + f"[CommercetoolsAPIClient] - Transitioning line item state for order with ID {order_id}" + f"from {from_state_key} to {new_state_key}" + ) try: if new_state_key != from_state_key: @@ -541,28 +683,40 @@ def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_ line_item_id=line_item_id, quantity=item_quantity, from_state=StateResourceIdentifier(key=from_state_key), - to_state=StateResourceIdentifier(key=new_state_key) + to_state=StateResourceIdentifier(key=new_state_key), ) - updated_fulfillment_line_item_order = self.base_client.orders.update_by_id( - id=order_id, - version=order_version, - actions=[transition_line_item_state_action], + updated_fulfillment_line_item_order = ( + self.base_client.orders.update_by_id( + id=order_id, + version=order_version, + actions=[transition_line_item_state_action], + ) ) return updated_fulfillment_line_item_order else: - logger.info(f"The line item {line_item_id} already has the correct state {new_state_key}. " - "Not attempting to transition LineItemState") + logger.info( + f"The line item {line_item_id} already has the correct state {new_state_key}. " + "Not attempting to transition LineItemState" + ) return self.get_order_by_id(order_id) except CommercetoolsError as err: # Logs & ignores version conflict errors due to duplicate Commercetools messages - handle_commercetools_error(err, f"Unable to update LineItemState of order {order_id}", True) + handle_commercetools_error( + err, f"Unable to update LineItemState of order {order_id}", True + ) 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: + 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: @@ -585,31 +739,30 @@ def retire_customer_anonymize_fields(self, customer_id: str, customer_version: i last_name=retired_last_name ) - update_retired_email_action = CustomerChangeEmailAction( - email=retired_email - ) + update_retired_email_action = CustomerChangeEmailAction(email=retired_email) update_retired_lms_username_action = CustomerSetCustomFieldAction( - name="edx-lms_user_name", - value=retired_lms_username + 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 - ]) + 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 + 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}") + 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 30876f6b..ffd044b2 100644 --- a/commerce_coordinator/apps/commercetools/pipeline.py +++ b/commerce_coordinator/apps/commercetools/pipeline.py @@ -345,7 +345,7 @@ def run_filter( updated_payment = ct_api_client.create_return_payment_transaction( payment_id=payment_on_order.id, payment_version=payment_on_order.version, - stripe_refund=refund_response + refund=refund_response ) return { diff --git a/commerce_coordinator/apps/commercetools/tasks.py b/commerce_coordinator/apps/commercetools/tasks.py index 5784023b..50ec5a63 100644 --- a/commerce_coordinator/apps/commercetools/tasks.py +++ b/commerce_coordinator/apps/commercetools/tasks.py @@ -9,9 +9,10 @@ from commercetools import CommercetoolsError from django.conf import settings +from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_PAYPAL_PAYMENT_INTERFACE_NAME from .clients import CommercetoolsAPIClient from .utils import has_full_refund_transaction -from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_PAYPAL_PAYMENT_INTERFACE_NAME + logger = logging.getLogger(__name__) diff --git a/commerce_coordinator/apps/commercetools/tests/test_clients.py b/commerce_coordinator/apps/commercetools/tests/test_clients.py index ff17e316..424bfa43 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_clients.py +++ b/commerce_coordinator/apps/commercetools/tests/test_clients.py @@ -598,7 +598,7 @@ def test_create_refund_transaction_exception(self): self.client_set.client.create_return_payment_transaction( payment_id="mock_payment_id", payment_version=1, - stripe_refund=mock_stripe_refund + refund=mock_stripe_refund ) exception = cm.exception diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index da60c997..ea1568b2 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -2,8 +2,9 @@ Paypal app views """ -import logging import base64 +import logging +from urllib.parse import urlparse import zlib import requests @@ -21,30 +22,39 @@ from commerce_coordinator.apps.paypal.signals import payment_refunded_signal from .models import KeyValueCache -from urllib.parse import urlparse + logger = logging.getLogger(__name__) class PayPalWebhookView(SingleInvocationAPIView): + """ + PayPal webhook view + """ ALLOWED_DOMAINS = ['www.paypal.com', 'api.paypal.com', 'api.sandbox.paypal.com', 'www.sandbox.paypal.com'] http_method_names = ["post"] authentication_classes = [] permission_classes = [AllowAny] def _get_certificate(self, url): + """ + Get certificate from the given URL + """ try: cache = KeyValueCache.objects.get(cache_key=url) return cache.value - except KeyValueCache.DoesNotExist: - if not self.is_valid_url(url): - raise ValueError("Invalid or untrusted URL provided") - r = requests.get(url) + except Exception as e: # pylint: disable=broad-exception-caught + if not self._is_valid_url(url): + raise ValueError("Invalid or untrusted URL provided") from e + r = requests.get(url) # pylint: disable=missing-timeout KeyValueCache.objects.create(cache_key=url, cache_value=r.text) return r.text - + def _is_valid_url(self, url): + """ + Check if the given URL is valid + """ try: parsed_url = urlparse(url) if parsed_url.scheme not in ['http', 'https']: @@ -52,10 +62,13 @@ def _is_valid_url(self, url): if parsed_url.netloc not in self.ALLOWED_DOMAINS: return False return True - except Exception: + except Exception: # pylint: disable=broad-exception-caught return False def post(self, request): + """ + Handle POST request + """ tag = type(self).__name__ body = request.body @@ -84,7 +97,7 @@ def post(self, request): public_key.verify( signature, message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256() ) - except Exception: + except Exception: # pylint: disable=broad-exception-caught return Response(status=status.HTTP_400_BAD_REQUEST) if request.data.get("event_type") == "PAYMENT.CAPTURE.REFUNDED":