diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 610c720..347dd8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,9 @@ jobs: matrix: include: - python-version: 3.8 - tox-env: py38 + tox-env: tests - python-version: 3.8 - tox-env: flake8 + tox-env: quality name: "Python ${{ matrix.python-version }} - ${{ matrix.tox-env }}" steps: @@ -27,14 +27,15 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache tox environments - uses: actions/cache@v3 + uses: actions/cache@v4 with: + save-always: true path: .tox # Refresh the cache if the following files change - key: "tox-${{ matrix.python-version }}-${{ matrix.tox-env }}-${{ hashFiles('tox.ini', 'setup.py', 'scripts/tox_install_ecommerce_run_pytest.sh', 'requirements/ecommerce-maple.master.txt', 'payfort-test.txt') }}" + key: "tox-${{ matrix.python-version }}-${{ matrix.tox-env }}-${{ hashFiles('tox.ini', 'setup.py', 'scripts/tox_install_ecommerce_run_pytest.sh', 'requirements/ecommerce-palm.master.txt', 'quality.txt') }}" - name: Install Dependencies run: | pip install tox - name: "Python ${{ matrix.python-version }} - ${{ matrix.tox-env }}" - run: "tox -e ${{ matrix.tox-env }}" + run: "tox -e py38-${{ matrix.tox-env }}" diff --git a/Makefile b/Makefile index 643a748..af531a0 100644 --- a/Makefile +++ b/Makefile @@ -12,10 +12,16 @@ download_ecommerce_requirements: tests: ## Run unit and integration tests - tox -e py38 - -unit_tests: ## Run unit tests - tox -e py38 -- tests/unit + tox -e py38-tests quality: ## Run code quality checks - tox -e flake8 + tox -e py38-quality + +translation.requirements: + pip install -r requirements/translation.txt + +translation.extract: + i18n_tool extract --no-segment + +translation.compile: + i18n_tool generate diff --git a/conf/locale b/conf/locale new file mode 120000 index 0000000..be6211e --- /dev/null +++ b/conf/locale @@ -0,0 +1 @@ +../ecommerce_payfort/locale \ No newline at end of file diff --git a/ecommerce_payfort/__init__.py b/ecommerce_payfort/__init__.py index 43aa411..b6da86b 100644 --- a/ecommerce_payfort/__init__.py +++ b/ecommerce_payfort/__init__.py @@ -1,4 +1,7 @@ """ The PayFort payment processor pluggable application for Open edX ecommerce. """ -default_app_config = 'ecommerce_payfort.apps.PayFortConfig' +default_app_config = 'ecommerce_payfort.apps.PayFortConfig' # pylint: disable=invalid-name + + +__version__ = '0.1.0' diff --git a/ecommerce_payfort/apps.py b/ecommerce_payfort/apps.py index 4e0698d..e4f9a8c 100644 --- a/ecommerce_payfort/apps.py +++ b/ecommerce_payfort/apps.py @@ -1,6 +1,4 @@ -""" -PayFort payment processor Django application initialization. -""" +"""PayFort payment processor Django application initialization.""" from django.apps import AppConfig diff --git a/ecommerce_payfort/locale/ar/LC_MESSAGES/django.po b/ecommerce_payfort/locale/ar/LC_MESSAGES/django.po index e341d8a..15994f9 100644 --- a/ecommerce_payfort/locale/ar/LC_MESSAGES/django.po +++ b/ecommerce_payfort/locale/ar/LC_MESSAGES/django.po @@ -21,3 +21,51 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Language: ar\n" "Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#: ecommerce_payfort/processors.py:22 +msgid "Checkout with credit card" +msgstr "الدفع ببطاقة الائتمان" + +#: ecommerce_payfort/templates/payfort_payment/form.html:12 +msgid "Redirecting to the Payment Gateway..." +msgstr "إعادة توجيه إلى بوابة الدفع..." + +#: ecommerce_payfort/templates/payfort_payment/handle_format_error.html:12 +#: ecommerce_payfort/templates/payfort_payment/handle_internal_error.html:12 +msgid "This is unfortunate and not expected!" +msgstr "هذا مؤسف وغير متوقع!" + +#: ecommerce_payfort/templates/payfort_payment/handle_format_error.html:14 +msgid "" +"The response came from the payment gateway is malformed but flagged as " +"succeeded! We're are not sure if your account has been charged or not! The " +"administrator has been notified and will investigate the issue shortly" +msgstr "" +"الرد الذي جاء من بوابة الدفع غير صحيح ولكنه يحتوي علامة الدفع الناجح! " +"لسنا متأكدين مما إذا تم اقتصاص المبلغ من حسابك أم لا! " +"تم إخطار الآدمن وسيقوم بالتحقيق في القضية قريباً" + +#: ecommerce_payfort/templates/payfort_payment/handle_format_error.html:16 +#: ecommerce_payfort/templates/payfort_payment/handle_internal_error.html:16 +msgid "" +"Please do not submit a purchase again before contacting the administrator" +msgstr "الرجاء عدم إعادة تقديم طلب شراء مرة أخرى قبل الاتصال بالآدمن" + +#: ecommerce_payfort/templates/payfort_payment/handle_format_error.html:19 +#: ecommerce_payfort/templates/payfort_payment/handle_internal_error.html:18 +#: ecommerce_payfort/templates/payfort_payment/wait_feedback.html:14 +msgid "For your reference, the payment ID is:" +msgstr "لمعلوماتك، رقم الدفع هو:" + +#: ecommerce_payfort/templates/payfort_payment/handle_internal_error.html:14 +msgid "" +"The payment has been processed successfully, but we had an unexpected error " +"while submitting the payment information to the server. The administrator " +"has been notified and will investigate the issue." +msgstr "" +"الدفع تم معالجته بنجاح، ولكن واجهنا خطأ غير متوقع أثناء تثبيت معلومات الدفع في الخادم. " +"تم إخطار الآدمن وسيقوم بالتحقيق في القضية." + +#: ecommerce_payfort/templates/payfort_payment/wait_feedback.html:12 +msgid "Payment succeeded. Processing enrollment.. please wait.." +msgstr "الدفع نجح. جاري معالجة التسجيل.. الرجاء الانتظار.." diff --git a/ecommerce_payfort/locale/en/LC_MESSAGES/django.po b/ecommerce_payfort/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..0d00d21 --- /dev/null +++ b/ecommerce_payfort/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,61 @@ +# edX translation file. +# Copyright (C) 2024 EdX +# This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE. +# EdX Team , 2024. +# +msgid "" +msgstr "" +"Project-Id-Version: 0.1a\n" +"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" +"POT-Creation-Date: 2023-06-13 08:00+0000\n" +"PO-Revision-Date: 2023-06-13 09:00+0000\n" +"Last-Translator: \n" +"Language-Team: openedx-translation \n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: ecommerce_payfort/processors.py:22 +msgid "Checkout with credit card" +msgstr "" + +#: ecommerce_payfort/templates/payfort_payment/form.html:12 +msgid "Redirecting to the Payment Gateway..." +msgstr "" + +#: ecommerce_payfort/templates/payfort_payment/handle_format_error.html:12 +#: ecommerce_payfort/templates/payfort_payment/handle_internal_error.html:12 +msgid "This is unfortunate and not expected!" +msgstr "" + +#: ecommerce_payfort/templates/payfort_payment/handle_format_error.html:14 +msgid "" +"The response came from the payment gateway is malformed but flagged as " +"succeeded! We're are not sure if your account has been charged or not! The " +"administrator has been notified and will investigate the issue shortly" +msgstr "" + +#: ecommerce_payfort/templates/payfort_payment/handle_format_error.html:16 +#: ecommerce_payfort/templates/payfort_payment/handle_internal_error.html:16 +msgid "" +"Please do not submit a purchase again before contacting the administrator" +msgstr "" + +#: ecommerce_payfort/templates/payfort_payment/handle_format_error.html:19 +#: ecommerce_payfort/templates/payfort_payment/handle_internal_error.html:18 +#: ecommerce_payfort/templates/payfort_payment/wait_feedback.html:14 +msgid "For your reference, the payment ID is:" +msgstr "" + +#: ecommerce_payfort/templates/payfort_payment/handle_internal_error.html:14 +msgid "" +"The payment has been processed successfully, but we had an unexpected error " +"while submitting the payment information to the server. The administrator " +"has been notified and will investigate the issue." +msgstr "" + +#: ecommerce_payfort/templates/payfort_payment/wait_feedback.html:12 +msgid "Payment succeeded. Processing enrollment.. please wait.." +msgstr "" diff --git a/ecommerce_payfort/processors.py b/ecommerce_payfort/processors.py index 5fd8b3e..1bd339b 100644 --- a/ecommerce_payfort/processors.py +++ b/ecommerce_payfort/processors.py @@ -1,31 +1,17 @@ -""" -PayFort payment processor. -""" - +"""PayFort payment processor.""" import logging +from urllib.parse import urljoin +from django.middleware.csrf import get_token +from django.urls import reverse from django.utils.translation import ugettext_lazy as _ -from oscar.apps.payment.exceptions import GatewayError +from ecommerce.extensions.payment.processors import BasePaymentProcessor, HandledProcessorResponse -from ecommerce.extensions.payment.processors import BasePaymentProcessor +from ecommerce_payfort import utils logger = logging.getLogger(__name__) -def format_price(price): - """ - Return the price in the expected format. - """ - return '{:0.2f}'.format(price) - - -class PayFortException(GatewayError): - """ - An umbrella exception to catch all errors from PayFort. - """ - pass # pylint: disable=unnecessary-pass - - class PayFort(BasePaymentProcessor): """ PayFort payment processor. @@ -33,9 +19,72 @@ class PayFort(BasePaymentProcessor): For reference, see https://paymentservices-reference.payfort.com/docs/api/build/index.html Scroll through the page, it's a very long single-page documentation. """ - - NAME = 'payfort' CHECKOUT_TEXT = _("Checkout with credit card") + NAME = "payfort" def __init__(self, site): + """Initialize the PayFort processor.""" + super().__init__(site) self.site = site + + self.access_code = self.configuration.get("access_code") + self.merchant_identifier = self.configuration.get("merchant_identifier") + self.request_sha_phrase = self.configuration.get("request_sha_phrase") + self.response_sha_phrase = self.configuration.get("response_sha_phrase") + self.sha_method = self.configuration.get("sha_method") + self.ecommerce_url_root = self.configuration.get("ecommerce_url_root") + + def get_transaction_parameters(self, basket, request=None, use_client_side_checkout=False, **kwargs): + """Return the transaction parameters needed for this processor.""" + transaction_parameters = { + "command": "PURCHASE", + "access_code": self.access_code, + "merchant_identifier": self.merchant_identifier, + "language": utils.get_language(request), + "merchant_reference": utils.get_merchant_reference(self.site.id, basket), + "amount": utils.get_amount(basket), + "currency": utils.get_currency(basket), + "customer_email": utils.get_customer_email(basket), + "customer_ip": utils.get_ip_address(request), + "order_description": utils.get_order_description(basket), + "customer_name": utils.get_customer_name(basket), + "return_url": urljoin( + self.ecommerce_url_root, + reverse("payfort:response") + ), + } + + signature = utils.get_signature( + self.request_sha_phrase, + self.sha_method, + transaction_parameters, + ) + transaction_parameters.update({ + "signature": signature, + "payment_page_url": reverse("payfort:form"), + "csrfmiddlewaretoken": get_token(request), + }) + + return transaction_parameters + + def handle_processor_response(self, response, basket=None): + """Handle the payment processor response and record the relevant details.""" + currency = response["currency"] + total = int(response["amount"]) / 100 + transaction_id = utils.get_transaction_id(response) + card_number = response.get("card_number") + card_type = response.get("payment_option") + + return HandledProcessorResponse( + transaction_id=transaction_id, + total=total, + currency=currency, + card_number=card_number, + card_type=card_type + ) + + def issue_credit( + self, order_number, basket, reference_number, amount, currency + ): # pylint: disable=too-many-arguments + """Not available.""" + raise NotImplementedError("PayFort processor cannot issue credits or refunds from Open edX ecommerce.") diff --git a/ecommerce_payfort/templates/payfort_payment/form.html b/ecommerce_payfort/templates/payfort_payment/form.html new file mode 100644 index 0000000..1b401be --- /dev/null +++ b/ecommerce_payfort/templates/payfort_payment/form.html @@ -0,0 +1,38 @@ +{% extends "edx/base.html" %} +{% load i18n %} + +{% block content %} + +
+

{% trans "Redirecting to the Payment Gateway..." %}

+
+ +
+ + + + + + + + + + + + + +
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/ecommerce_payfort/templates/payfort_payment/handle_format_error.html b/ecommerce_payfort/templates/payfort_payment/handle_format_error.html new file mode 100644 index 0000000..6b41c9f --- /dev/null +++ b/ecommerce_payfort/templates/payfort_payment/handle_format_error.html @@ -0,0 +1,23 @@ +{% extends "edx/base.html" %} +{% load i18n %} + +{% block content %} + +
+

{% trans "This is unfortunate and not expected!" %}

+ +

{% trans "The response came from the payment gateway is malformed but flagged as succeeded! We're are not sure if your account has been charged or not! The administrator has been notified and will investigate the issue shortly" %}

+ +

{% trans "Please do not submit a purchase again before contacting the administrator" %}

+ + {% if reference != "none" %} +

{% trans "For your reference, the payment ID is:" %} {{ reference }}

+ {% endif %} +
+ +{% endblock %} diff --git a/ecommerce_payfort/templates/payfort_payment/handle_internal_error.html b/ecommerce_payfort/templates/payfort_payment/handle_internal_error.html new file mode 100644 index 0000000..e164f36 --- /dev/null +++ b/ecommerce_payfort/templates/payfort_payment/handle_internal_error.html @@ -0,0 +1,21 @@ +{% extends "edx/base.html" %} +{% load i18n %} + +{% block content %} + +
+

{% trans "This is unfortunate and not expected!" %}

+ +

{% trans "The payment has been processed successfully, but we had an unexpected error while submitting the payment information to the server. The administrator has been notified and will investigate the issue." %}

+ +

{% trans "Please do not submit a purchase again before contacting the administrator" %}

+ +

{% trans "For your reference, the payment ID is:" %} {{ merchant_reference }}

+
+ +{% endblock %} diff --git a/ecommerce_payfort/templates/payfort_payment/wait_feedback.html b/ecommerce_payfort/templates/payfort_payment/wait_feedback.html new file mode 100644 index 0000000..87e431b --- /dev/null +++ b/ecommerce_payfort/templates/payfort_payment/wait_feedback.html @@ -0,0 +1,66 @@ +{% extends "edx/base.html" %} +{% load i18n %} + +{% block content %} + +
+

{% trans "Payment succeeded. Processing enrollment.. please wait.." %}

+ +

{% trans "For your reference, the payment ID is:" %} {{ ecommerce_transaction_id }}

+
+{% endblock %} + + +{% block javascript %} + +{% endblock %} diff --git a/ecommerce_payfort/templates/payment/payfort.html b/ecommerce_payfort/templates/payment/payfort.html deleted file mode 100644 index edc69ad..0000000 --- a/ecommerce_payfort/templates/payment/payfort.html +++ /dev/null @@ -1,43 +0,0 @@ -{% load i18n %} -{% load static %} -{% load compress %} - - - - - - {% trans "PayFort" %} - {{ payment_mode }} - {% compress css %} - {% if main_css %} - - {% else %} - - {% endif %} - {% endcompress %} - - {% compress css %} - {# This block is separated to better support browser caching. #} - {% block stylesheets %} - {% endblock %} - {% endcompress %} - - - - - - {# This adds the header for the page. #} - {% include 'edx/partials/_student_navbar.html' %} -
-
- - {% compress js %} - - - - Note: django-compressor does not recognize the data-main attribute. Load the main script separately. - - {% endcompress %} - - diff --git a/ecommerce_payfort/tests/mixins.py b/ecommerce_payfort/tests/mixins.py deleted file mode 100644 index 4d51704..0000000 --- a/ecommerce_payfort/tests/mixins.py +++ /dev/null @@ -1,30 +0,0 @@ -import responses -from django.conf import settings -from oscar.core.loading import get_class, get_model -from six.moves.urllib.parse import urljoin - -CURRENCY = 'SAR' -Basket = get_model('basket', 'Basket') -Order = get_model('order', 'Order') -PaymentEventType = get_model('order', 'PaymentEventType') -PaymentProcessorResponse = get_model('payment', 'PaymentProcessorResponse') -SourceType = get_model('payment', 'SourceType') - -post_checkout = get_class('checkout.signals', 'post_checkout') - - -class PayFortMixin: - """ - Mixin with helper methods for mocking PayFort API responses. - """ - - def mock_api_response(self, path, body, method=responses.POST, resp=responses): - url = self._create_api_url(path=path) - resp.add(method, url, json=body) - - def _create_api_url(self, path): - """ - Returns the API URL - """ - base_url = settings.PAYMENT_PROCESSOR_CONFIG['edx']['payfort']['payfort_base_api_url'] - return urljoin(base_url, path) diff --git a/ecommerce_payfort/tests/processors/test_payfort.py b/ecommerce_payfort/tests/processors/test_payfort.py deleted file mode 100644 index b0a3390..0000000 --- a/ecommerce_payfort/tests/processors/test_payfort.py +++ /dev/null @@ -1,12 +0,0 @@ -import ddt -import pytest - -from ecommerce.extensions.payment.tests.processors.mixins import PaymentProcessorTestCaseMixin -from ecommerce.tests.testcases import TestCase -from ecommerce_payfort.tests.mixins import PayFortMixin - - -@ddt.ddt -@pytest.mark.xfail -class PayFortTests(PayFortMixin, PaymentProcessorTestCaseMixin, TestCase): - pass diff --git a/ecommerce_payfort/tests/test_mixins.py b/ecommerce_payfort/tests/test_mixins.py new file mode 100644 index 0000000..d305c90 --- /dev/null +++ b/ecommerce_payfort/tests/test_mixins.py @@ -0,0 +1,44 @@ +"""Mixin classes for testing.""" +from unittest import TestCase +from unittest.mock import patch + +import pytest + + +class MockPatcherMixin(TestCase): + """Mixin class to automatically start and stop a mock patcher.""" + patching_config = None + + @classmethod + def setUpClass(cls): + """ Set up the test class. """ + super().setUpClass() + if cls.patching_config is None: + raise ValueError("Fill patching_config attribute, or remove MockPatcherMixin from the inheritance chain.") + + cls.patchers = { + name: patch(patch_config[0], **patch_config[1]) + for name, patch_config in cls.patching_config.items() + } + + def setUp(self): + """Set up the test.""" + super().setUp() + self.mocks = {name: patcher.start() for name, patcher in self.patchers.items()} + + def tearDown(self): + """Tear down the test.""" + for patcher in self.patchers.values(): + patcher.stop() + super().tearDown() + + +def test_mock_patcher_mixin(): + """Test the MockPatcherMixin.""" + class TestMockPatcherMixin(MockPatcherMixin): + """Test class for the MockPatcherMixin.""" + + with pytest.raises(ValueError) as exc_info: + TestMockPatcherMixin.setUpClass() + assert str(exc_info.value) == \ + "Fill patching_config attribute, or remove MockPatcherMixin from the inheritance chain." diff --git a/ecommerce_payfort/tests/test_processors.py b/ecommerce_payfort/tests/test_processors.py new file mode 100644 index 0000000..02eb07f --- /dev/null +++ b/ecommerce_payfort/tests/test_processors.py @@ -0,0 +1,102 @@ +""" Tests for the PayFort payment processor. """ +from unittest.mock import patch +import ddt +from django.conf import settings as django_settings +from ecommerce.extensions.payment.processors import HandledProcessorResponse +from ecommerce.extensions.payment.tests.processors.mixins import PaymentProcessorTestCaseMixin +from ecommerce.tests.testcases import TestCase + +from ecommerce_payfort.processors import PayFort +from ecommerce_payfort import utils + + +@ddt.ddt +class PayFortTests(PaymentProcessorTestCaseMixin, TestCase): # pylint: disable=too-many-ancestors + """ Tests for the PayFort payment processor. """ + processor_name = "payfort" + processor_class = PayFort + + @classmethod + def setUpClass(cls): + """ Set up the test class. """ + super().setUpClass() + cls.patcher = patch("ecommerce_payfort.utils.get_currency", return_value=utils.VALID_CURRENCY) + cls.mock_get_currency = cls.patcher.start() + + @classmethod + def tearDownClass(cls): + """ Tear down the test class. """ + cls.patcher.stop() + super().tearDownClass() + + def test_init(self): + """ Verify the processor initializes from the configuration. """ + settings = django_settings.PAYMENT_PROCESSOR_CONFIG["edx"]["payfort"] + processor = self.processor_class(self.site) + self.assertEqual(processor.site, self.site) + self.assertEqual(processor.access_code, settings["access_code"]) + self.assertEqual(processor.merchant_identifier, settings["merchant_identifier"]) + self.assertEqual(processor.request_sha_phrase, settings["request_sha_phrase"]) + self.assertEqual(processor.response_sha_phrase, settings["response_sha_phrase"]) + self.assertEqual(processor.sha_method, settings["sha_method"]) + self.assertEqual(processor.ecommerce_url_root, settings["ecommerce_url_root"]) + + def test_handle_processor_response(self): + """ Verify that the processor creates the appropriate PaymentEvent and Source objects. """ + with patch("ecommerce_payfort.utils.get_transaction_id", return_value="1234567890"): + response = { + "amount": "2000", + "currency": "SAR", + "card_number": "1234", + "payment_option": "VISA", + } + expected_result = HandledProcessorResponse( + transaction_id="1234567890", + total=20.0, + currency="SAR", + card_number="1234", + card_type="VISA" + ) + actual_result = self.processor.handle_processor_response(response) + self.assertEqual(expected_result, actual_result) + + def test_get_transaction_parameters(self): + """ Verify the processor returns the appropriate parameters required to complete a transaction. """ + customer_ip = "199.199.199.199" + expected_result = { + "command": "PURCHASE", + "access_code": "123123123", + "merchant_identifier": "mid123", + "language": "en", + "merchant_reference": f"{self.request.site.id}-{self.basket.owner.id}-{self.basket.id}", + "amount": 2000, + "currency": "SAR", + "customer_email": self.basket.owner.email, + "customer_ip": customer_ip, + "order_description": f"1 X {self.basket.all_lines()[0].product.course.id}", + "customer_name": "Ecommerce User", + "return_url": "http://myecommerce.mydomain.com/payfort/response/", + } + with patch("ecommerce_payfort.utils.get_ip_address", return_value=customer_ip): + actual_result = self.processor.get_transaction_parameters(self.basket, request=self.request) + actual_result.pop("csrfmiddlewaretoken") + actual_result.pop("payment_page_url") + expected_result["signature"] = utils.get_signature( + self.processor.request_sha_phrase, + self.processor.sha_method, + expected_result, + ) + print("actual_result: ", actual_result) + self.assertDictEqual(expected_result, actual_result) + + def test_issue_credit_error(self): + """not used""" + + def test_issue_credit(self): + """Verify that issue_credit raises a NotImplementedError.""" + with self.assertRaises(NotImplementedError) as exc: + self.processor.issue_credit("order_number", self.basket, "reference_number", 2000, "SAR") + self.assertEqual( + str(exc.exception), + "PayFort processor cannot issue credits or refunds from Open edX ecommerce." + ) diff --git a/ecommerce_payfort/tests/test_utils.py b/ecommerce_payfort/tests/test_utils.py new file mode 100644 index 0000000..9010538 --- /dev/null +++ b/ecommerce_payfort/tests/test_utils.py @@ -0,0 +1,423 @@ +"""Tests for payfort_utils.py""" +from unittest.mock import Mock, patch +import pytest + +from ecommerce_payfort import utils +from ecommerce_payfort.utils import verify_param as original_verify_param + + +class MockBasket: # pylint: disable=too-few-public-methods + """Mocked Basket class.""" + class Product: # pylint: disable=too-few-public-methods + """Mocked Product class.""" + def __init__(self, course_key=None, title=None, parent=None): + if course_key: + self.course = Mock(id=course_key) + else: + self.course = None + self.title = title + self.parent = parent + + class Line: # pylint: disable=too-few-public-methods + """Mocked Line class.""" + def __init__(self, product, quantity=1, price_currency=None): + self.product = product + self.quantity = quantity + self.price_currency = price_currency + + class Owner: # pylint: disable=too-few-public-methods + """Mocked Owner class.""" + def __init__(self): + """Initialize the owner.""" + self.email = "test@example.com" + self.the_full_name = "Test User" + + def get_full_name(self): + """Return the full name of the owner.""" + return self.the_full_name + + def __init__(self): + """Initialize the basket.""" + self.id = 1 # pylint: disable=invalid-name + self.owner = self.Owner() + self.owner_id = 77 + self.total_incl_tax = 98.765 + self.internal_lines = [ + self.Line(self.Product(course_key="course-v1:C1+CC1+2024")), + self.Line(self.Product(course_key="course-v1:C2+CC2+2024"), price_currency="SAR"), + ] + self.all_lines = lambda: self.internal_lines + + +def mocked_verify_param_basket(param, param_name, required_type): + """Mocked verify_param function.""" + if required_type == utils.Basket: + return + original_verify_param(param, param_name, required_type) + + +@pytest.fixture +def mocked_basket(): + """Create a basket for testing.""" + basket = MockBasket() + with patch("ecommerce_payfort.utils.verify_param", side_effect=mocked_verify_param_basket): + yield basket + + +@pytest.fixture +def valid_response_data(): + """Return valid response data.""" + return { + "merchant_reference": "1-2-1", + "command": "PURCHASE", + "merchant_identifier": "mid123", + "amount": "2000", + "currency": "SAR", + "response_code": "200", + "signature": "6eaf677bcdea16fc186dfdbf405ecc6f472094dff73ce699cc3afedc376a81d7", + "status": "14", + "eci": "eci-value", + "fort_id": "fort-id-value", + } + + +@pytest.mark.parametrize( + "text_to_sanitize, valid_pattern, expected_result", + [ + ("", "", ""), + (None, None, ""), + ("whatever", None, ""), + ("whatever", "", ""), + ( + "Some text with $ sign, a plus + and numbers like 123 and a dash -!!", + r"[^A-Za-z0-9 !]", + "Some text with _ sign_ a plus _ and numbers like 123 and a dash _!!", + ), + ] +) +def test_sanitize_text_defaults(text_to_sanitize, valid_pattern, expected_result): + """ + Verify that the text is sanitized correctly using sanitize_text using default replacement and with no max_length. + """ + assert utils.sanitize_text(text_to_sanitize, valid_pattern) == expected_result + + +@pytest.mark.parametrize( + "max_length, expected_result", + [ + (-1, "Some text with _ sign and numbers like 123 and a dash _!!"), + (0, "Some text with _ sign and numbers like 123 and a dash _!!"), + (20, "Some text with _ ..."), + ] +) +def test_sanitize_text_max_length(max_length, expected_result): + """Verify that the text is sanitized correctly using sanitize_text with a max_length.""" + assert utils.sanitize_text( + "Some text with $ sign and numbers like 123 and a dash -!!", + r"[^A-Za-z0-9 !\.]", + max_length + ) == expected_result + + +def test_sanitize_text_max_length_dots_not_allowed(): + """Verify that the text is sanitized correctly using sanitize_text with a max_length.""" + assert utils.sanitize_text( + "Some text with $ sign and numbers like 123 and a dash -!!", + r"[^A-Za-z0-9 !]", + 20 + ) == "Some text with _ sig" + + +def test_verify_param(): + """Verify that verify_param raises an exception if the parameter is None or not of the required type.""" + with pytest.raises(utils.PayFortException) as exc: + utils.verify_param(None, "param", str) + assert "verify_param failed: param is required and must be (str), but got (NoneType)" in str(exc.value) + with pytest.raises(utils.PayFortException) as exc: + utils.verify_param("param", "param", int) + assert "verify_param failed: param is required and must be (int), but got (str)" in str(exc.value) + + +def test_get_amount(mocked_basket): # pylint: disable=redefined-outer-name + """Verify that get_amount returns the amount of the basket.""" + assert utils.get_amount(mocked_basket) == 9876 + + +def test_get_currency(mocked_basket): # pylint: disable=redefined-outer-name + """Verify that get_currency returns the currency of the basket.""" + assert utils.get_currency(mocked_basket) == "SAR" + + +def test_get_currency_bad_one(mocked_basket): # pylint: disable=redefined-outer-name + """Verify that get_currency raises an exception if the currency is not supported.""" + mocked_basket.internal_lines.append( + mocked_basket.Line(mocked_basket.Product("course-v1:C1+CC1+2024"), price_currency="USD") + ) + with pytest.raises(utils.PayFortException) as exc: + utils.get_currency(mocked_basket) + assert "Currency not supported: USD" in str(exc) + + +def test_get_customer_email(mocked_basket): # pylint: disable=redefined-outer-name + """Verify that get_customer_email returns the email of the basket owner.""" + assert utils.get_customer_email(mocked_basket) == "test@example.com" + + +def test_get_customer_name(mocked_basket): # pylint: disable=redefined-outer-name + """Verify that get_customer_name returns the name of the basket owner.""" + assert utils.get_customer_name(mocked_basket) == "Test User" + + +def test_get_customer_name_sanitized(mocked_basket): # pylint: disable=redefined-outer-name + """Verify that get_customer_name returns the name of the basket owner after sanitizing it.""" + mocked_basket.owner.the_full_name = "Good _\\/-.' Bad!+%^*()[@+123]<> Arabic عربي" + assert utils.get_customer_name(mocked_basket) == "Good _\\/-.' Bad________________ Arabic ____" + + +@pytest.mark.parametrize( + "lang_code, expected_result", + [ + ("ar", "ar"), + ("en", "en"), + ("AR", "ar"), + ("Ar", "ar"), + ("ar-SA", "ar"), + ] +) +def test_get_language(lang_code, expected_result): + """Verify that get_language returns the default language if the request has no language.""" + request = Mock(LANGUAGE_CODE=lang_code) + assert utils.get_language(request) == expected_result + + +def test_get_language_no_request(): + """Verify that get_language returns the default language if the request is None.""" + assert utils.get_language(None) == "en" + + +def test_get_language_no_language(): + """Verify that get_language returns the default language if the request has no language.""" + request = Mock() + delattr(request, "LANGUAGE_CODE") # pylint: disable=literal-used-as-attribute + assert not hasattr(request, "LANGUAGE_CODE") + assert utils.get_language(request) == "en" + + +@pytest.mark.parametrize( + "lang_code", + [ + "bad", + "fr", + "bad-bb", + "fr-fr", + ] +) +def test_get_language_bad_language(lang_code): + """Verify that get_language returns the default language if the request has anything other than en or ar.""" + request = Mock(LANGUAGE_CODE=lang_code) + assert utils.get_language(request) == "en" + + +def test_get_merchant_reference(mocked_basket): # pylint: disable=redefined-outer-name + """Verify that get_merchant_reference returns a valid merchant reference.""" + assert utils.get_merchant_reference(26, mocked_basket) == "26-77-1" + + +@pytest.mark.parametrize( + "self_key, self_title, parent_key, parent_title, expected_with_parent, expected_without_parent", + [ + (True, True, True, True, "self_key", "self_key"), + (True, True, True, False, "self_key", "self_key"), + (True, True, False, True, "self_key", "self_key"), + (True, True, False, False, "self_key", "self_key"), + (True, False, True, True, "self_key", "self_key"), + (True, False, True, False, "self_key", "self_key"), + (True, False, False, True, "self_key", "self_key"), + (True, False, False, False, "self_key", "self_key"), + (False, True, True, True, "parent_key", "self_title"), + (False, True, True, False, "parent_key", "self_title"), + (False, True, False, True, "self_title", "self_title"), + (False, True, False, False, "self_title", "self_title"), + (False, False, True, True, "parent_key", "-"), + (False, False, True, False, "parent_key", "-"), + (False, False, False, True, "parent_title", "-"), + (False, False, False, False, "-", "-"), + ] +) +def test_get_order_description( + mocked_basket, self_key, self_title, parent_key, parent_title, expected_with_parent, expected_without_parent +): # pylint: disable=redefined-outer-name, too-many-arguments + """ + Verify that get_order_description returns a valid order description from the related fields: + - product course_key + - if no course in the product; then parent's product course_key + - if no course in the parent product; then product title + - if no title in the product; then parent's product title + """ + parent = mocked_basket.Product( + course_key="parent_key" if parent_key else None, + title="parent_title" if parent_title else None + ) + product = mocked_basket.Product( + course_key="self_key" if self_key else None, + title="self_title" if self_title else None, + ) + + mocked_basket.internal_lines.append(mocked_basket.Line(product, quantity=2)) + assert utils.get_order_description( + mocked_basket + ) == f"1 X course-v1:C1_CC1_2024 // 1 X course-v1:C2_CC2_2024 // 2 X {expected_without_parent}" + + product.parent = parent + assert utils.get_order_description( + mocked_basket + ) == f"1 X course-v1:C1_CC1_2024 // 1 X course-v1:C2_CC2_2024 // 2 X {expected_with_parent}" + + +def test_get_signature(): + """Verify that get_signature returns a valid signature.""" + assert utils.get_signature( + "secret!", "SHA-256", {"param1": "value1", "param2": "value2"} + ) == "811171c0e6a56ed10e69f0954a20aeeef71b4003303165ae16e9e02d7d659d73" + + +def test_get_signature_bad_method(): + """Verify that get_signature raises an exception if the method is not supported.""" + with pytest.raises(utils.PayFortException) as exc: + utils.get_signature("any", "bad_method", {"param1": "value1", "param2": "value2"}) + assert "Unsupported SHA method: bad_method" in str(exc) + + +@pytest.mark.parametrize( + "response_data, expected_result", + [ + ((None, None), "none-none"), + (("eci-value", None), "eci-value-none"), + ((None, "fort-id-value"), "none-fort-id-value"), + (("", ""), "none-none"), + (("eci-value", ""), "eci-value-none"), + (("", "fort-id-value"), "none-fort-id-value"), + (("eci-value", "fort-id-value"), "eci-value-fort-id-value"), + (("absence", "fort-id-value"), "none-fort-id-value"), + (("eci-value", "absence"), "eci-value-none"), + (("absence", "absence"), "none-none"), + ] +) +def test_get_transaction_id(response_data, expected_result): + """Verify that get_transaction_id returns a valid transaction ID.""" + data = {"eci": response_data[0], "fort_id": response_data[1]} + if response_data[0] == "absence": + data.pop("eci") + if response_data[1] == "absence": + data.pop("fort_id") + assert utils.get_transaction_id(data) == expected_result + + +def test_verify_response_format(valid_response_data): # pylint: disable=redefined-outer-name + """Verify that verify_response_format returns successfully if the response format is valid.""" + utils.verify_response_format(valid_response_data) + + +def test_verify_response_format_guard(valid_response_data): # pylint: disable=redefined-outer-name + """Protect MANDATORY_RESPONSE_FIELDS from being changed by mistake.""" + assert utils.MANDATORY_RESPONSE_FIELDS == [ + "merchant_reference", "command", "merchant_identifier", "amount", + "currency", "response_code", "signature", "status", + ] + for field in utils.MANDATORY_RESPONSE_FIELDS: + assert field in valid_response_data + + +def test_verify_response_format_missing(valid_response_data): # pylint: disable=redefined-outer-name + """Verify that verify_response_format raises an exception if a mandatory field is missing.""" + for field in utils.MANDATORY_RESPONSE_FIELDS: + data = valid_response_data.copy() + data.pop(field) + with pytest.raises(utils.PayFortException) as exc: + utils.verify_response_format(data) + assert f"Missing field in response: {field}" in str(exc) + + +def test_verify_response_format_not_string(valid_response_data): # pylint: disable=redefined-outer-name + """Verify that verify_response_format raises an exception if a mandatory field is missing.""" + for field in utils.MANDATORY_RESPONSE_FIELDS: + data = valid_response_data.copy() + data.update({field: 123}) + with pytest.raises(utils.PayFortException) as exc: + utils.verify_response_format(data) + assert f"Invalid field type in response: {field}. Should be , but got " in str(exc) + + +@pytest.mark.parametrize( + "field, value, expected_error_msg", + [ + ("amount", "abc", "Invalid amount in response (not a positive integer): abc"), + ("amount", "1.2", "Invalid amount in response (not a positive integer): 1.2"), + ("amount", "-1", "Invalid amount in response (not a positive integer): -1"), + ("currency", "USD", "Invalid currency in response: USD"), + ("command", "AUTHORIZATION", "Invalid command in response: AUTHORIZATION"), + ("merchant_reference", "1-2-3-4", "Invalid merchant_reference in response: 1-2-3-4"), + ("eci", None, "Unexpected successful payment that lacks eci or fort_id"), + ("fort_id", None, "Unexpected successful payment that lacks eci or fort_id"), + ] +) +def test_verify_response_format_bad( + valid_response_data, field, value, expected_error_msg +): # pylint: disable=redefined-outer-name + """Verify that verify_response_format returns successfully if the response format is valid.""" + valid_response_data[field] = value + with pytest.raises(utils.PayFortException) as exc: + utils.verify_response_format(valid_response_data) + assert expected_error_msg in str(exc) + + +def test_verify_signature(valid_response_data): # pylint: disable=redefined-outer-name + """Verify that verify_signature returns successfully if the signature is valid.""" + utils.verify_signature("secret@res", "SHA-256", valid_response_data) + + +def test_verify_signature_invalid(valid_response_data): # pylint: disable=redefined-outer-name + """Verify that verify_signature raises an exception if the signature is invalid.""" + valid_response_data["signature"] = "invalid_signature" + + with pytest.raises(utils.PayFortBadSignatureException) as exc: + utils.verify_signature("secret@res", "SHA-256", valid_response_data) + assert "Response signature mismatch" in str(exc) + + +def test_verify_signature_missing(valid_response_data): # pylint: disable=redefined-outer-name + """Verify that verify_signature raises an exception if the signature is missing.""" + valid_response_data.pop("signature") + + with pytest.raises(utils.PayFortBadSignatureException) as exc: + utils.verify_signature("secret@res", "SHA-256", valid_response_data) + assert "Signature not found!" in str(exc) + + +def test_verify_signature_bad_method(valid_response_data): # pylint: disable=redefined-outer-name + """Verify that verify_signature raises an exception if the method is not supported.""" + with pytest.raises(utils.PayFortException) as exc: + utils.verify_signature("secret@res", "bad_method", valid_response_data) + assert "Unsupported SHA method: bad_method" in str(exc) + + +def test_get_ip_address_no_request(): + """Verify that get_ip_address returns a valid IP address when the request is None.""" + assert utils.get_ip_address(None) == "" + + +def test_get_ip_address_with_proxy(): + """Verify that get_ip_address returns a valid IP address when a proxy is used.""" + request = Mock(META={ + "HTTP_X_FORWARDED_FOR": "1.1.1.1, 2.2.2.2, 3.3.3.3", + "REMOTE_ADDR": "should be ignored", + }) + assert utils.get_ip_address(request) == "1.1.1.1" + + +def test_get_ip_address_no_proxy(): + """Verify that get_ip_address returns a valid IP address when no proxy is used.""" + request = Mock(META={ + "REMOTE_ADDR": " 4.4.4.4 ", + }) + assert utils.get_ip_address(request) == "4.4.4.4" diff --git a/ecommerce_payfort/tests/test_views.py b/ecommerce_payfort/tests/test_views.py new file mode 100644 index 0000000..34a5d0a --- /dev/null +++ b/ecommerce_payfort/tests/test_views.py @@ -0,0 +1,578 @@ +"""Test the views of the app.""" +import json +import logging +import unittest +from unittest.mock import Mock, patch, PropertyMock + +import ddt +from django.contrib.auth.models import AnonymousUser +from django.core.handlers.wsgi import WSGIRequest +from django.http import Http404 +from django.test import Client, RequestFactory +from django.urls import reverse +from ecommerce.tests.factories import UserFactory +from ecommerce.tests.testcases import TestCase + +from ecommerce_payfort import utils +from ecommerce_payfort import views +from ecommerce_payfort.processors import PayFort +from ecommerce_payfort.tests.test_mixins import MockPatcherMixin + + +class BaseTests(TestCase): # pylint: disable=too-many-ancestors + """Base test class.""" + def setUp(self): + """Set up the test.""" + super().setUp() + self.client = Client() + self.user = UserFactory(username="testuser", password="12345") + self.payment_data = { + "command": "payment-command", + "access_code": "access-code", + "merchant_identifier": "something", + "merchant_reference": "a reference", + "amount": "an integer", + "currency": "FAKE", + "language": "en", + "customer_email": "me@example.com", + "customer_ip": "1.1.1.1", + "order_description": "whatever", + "signature": "long-string-after-encryption", + "customer_name": "John Doe", + "return_url": "/payfort/response/", + } + + def login(self): + """Log in the user.""" + self.client.login(username="testuser", password="12345") + + +class TestPayFortPaymentRedirectView(BaseTests): # pylint: disable=too-many-ancestors + """Test the PayFortPaymentRedirectView.""" + after_signature_keys = [ + "payment_page_url", + "csrfmiddlewaretoken", + ] + + def test_post(self): + """Test the POST method.""" + self.login() + response = self.client.post(reverse("payfort:form"), self.payment_data) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "payfort_payment/form.html") + + content = response.content.decode("utf-8") + self.assertIn("Redirecting to the Payment Gateway...", content) + for key, value in self.payment_data.items(): + self.assertIn(f"", content) + + def test_must_be_logged_in(self): + """Test the POST method.""" + response = self.client.post(reverse("payfort:form"), self.payment_data) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, "/login/?next=/payfort/pay/") + + def test_payment_data(self): + """Verify that payment data is set for all fields except those added after calculating the signature.""" + processor = PayFort(self.site) + with patch("ecommerce_payfort.processors.utils"), patch("ecommerce_payfort.processors.get_token"): + transaction_parameters = processor.get_transaction_parameters(Mock()) + + for key in self.payment_data: + self.assertIn( + key, transaction_parameters, + f"payment_data key ({key}) not found in transaction_parameters. This means that you've removed fields" + " from get_transaction_parameters but you forgot to remove them from the payment_data in the test." + ) + transaction_parameters.pop(key) + for key in self.after_signature_keys: + self.assertIn( + key, transaction_parameters, + f"after_signature_keys key ({key}) found in transaction_parameters. This means that you've removed" + " fields from get_transaction_parameters after calculating the signature but you forgot to remove them" + " from the after_signature_keys in the test." + ) + transaction_parameters.pop(key) + + self.assertFalse( + transaction_parameters, + "transaction_parameters is not empty! this means that you've added new fields to the processor" + " but you forgot to add them to the payment_data in the test. Adding the new fields to the payment_data" + " will also require adding them to `form.html`, unless they are ecommerce-specific fields that are added" + " after calculating the signature. If so, then add them only to the `after_signature_keys` list in the test" + " and not in the payment_data nor in the `form.html`." + "\nThis test is your guard to avoid missing up the synchronization between get_transaction_parameters" + " and the form." + ) + + +class TestPayFortPaymentHandleInternalErrorView(BaseTests): # pylint: disable=too-many-ancestors + """Test the PayFortPaymentHandleInternalErrorView.""" + def test_get(self): + """Test the GET method.""" + response = self.client.get(reverse("payfort:handle-internal-error", args=["merchant_reference_value"])) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "payfort_payment/handle_internal_error.html") + self.assertIn( + "For your reference, the payment ID is: merchant_reference_value", + response.content.decode("utf-8") + ) + + +class TestPayFortPaymentHandleFormatErrorView(BaseTests): # pylint: disable=too-many-ancestors + """Test the PayFortPaymentHandleFormatErrorView.""" + def test_get(self): + """Test the GET method.""" + response = self.client.get(reverse("payfort:handle-format-error", args=["a_reference_value"])) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "payfort_payment/handle_format_error.html") + self.assertIn( + "For your reference, the payment ID is: a_reference_value", + response.content.decode("utf-8") + ) + + +class TestPayFortCallBaseView(MockPatcherMixin, BaseTests): # pylint: disable=too-many-ancestors + """Test the PayFortCallBaseView.""" + class DerivedView(views.PayFortCallBaseView): + """Derived view for testing __class__.__name__ validity.""" + + patching_config = { + "get_transaction_id": ("ecommerce_payfort.utils.get_transaction_id", { + "return_value": "the-transaction-id", + }), + "log_error": ("ecommerce_payfort.views.PayFortCallBaseView.log_error", {}), + "verify_signature": ("ecommerce_payfort.utils.verify_signature", { + "autospec": True + }), + "verify_response_format": ("ecommerce_payfort.utils.verify_response_format", { + "autospec": True + }), + } + + def setUp(self): + """Set up the test.""" + super().setUp() + self.view = views.PayFortCallBaseView() + + def _set_request(self, data, method="post", path="/", user=None): + """Helper method to set the request.""" + request = getattr(RequestFactory(), method)(path, data) + request.user = AnonymousUser() if user is None else user + self.view.request = request + + def test_basket_with_basket_set(self): + """Verify that basket property reads from the cached object.""" + self.view._basket = "test_basket" # pylint: disable=protected-access + self.assertEqual(self.view.basket, "test_basket") + + def test_basket_with_no_request(self): + """Verify that basket property returns None when request is None.""" + self.view.request = None + self.assertIsNone(self.view.basket) + + def test_basket_with_non_existent_basket(self): + """Verify that basket property returns None when the basket does not exist.""" + self._set_request(data={"merchant_reference": "test-1"}) + self.assertIsNone(self.view.basket) + + def test_basket_with_existent_basket(self): + """Verify that basket property returns the basket when it exists.""" + basket = utils.Basket.objects.create() + self._set_request(data={"merchant_reference": f"test-{basket.id}"}) + self.assertEqual(self.view.basket, basket) + + def test_basket_with_existent_basket_bad_merchant_reference(self): + """Verify that basket property returns the basket when it exists.""" + basket = utils.Basket.objects.create() + self._set_request(data={"merchant_reference": f"test{basket.id}"}) + self.assertIsNone(self.view.basket) + + def test_basket_with_existent_basket_with_missing_merchant_reference(self): + """Verify that basket property returns None when merchant_reference is missing.""" + utils.Basket.objects.create() + self._set_request(data={}) + self.assertIsNone(self.view.basket) + + @patch.object(logging.Logger, 'error') + def test_log_error(self, mock_logger_error): + """Verify that log_error method logs the error message.""" + self.patchers["log_error"].stop() + self.DerivedView().log_error("Test message 1") + mock_logger_error.assert_called_once_with("%s: %s", "DerivedView", "Test message 1") + self.mocks["log_error"] = self.patchers["log_error"].start() + + def test_save_payment_processor_response(self): + """Verify that save_payment_processor_response calls the record_processor_response method.""" + view = self.DerivedView() + view.payment_processor = Mock(record_processor_response=Mock()) + view._basket = Mock(id=7, total_incl_tax=456.78, currency="FAKE") # pylint: disable=protected-access + view.save_payment_processor_response({"any": "any"}) + view.payment_processor.record_processor_response.assert_called_once_with( + response={ + "view": "DerivedView", + "response": {"any": "any"} + }, + transaction_id="the-transaction-id", + basket=view.basket, + ) + + def test_save_payment_processor_response_exception(self): + """Verify that save_payment_processor_response logs the exception when record_processor_response fails.""" + view = self.DerivedView() + view.payment_processor = Mock(record_processor_response=Mock(side_effect=Exception("Test exception"))) + + with self.assertRaises(Http404): + view.save_payment_processor_response({"merchant_reference": "test_ref"}) + + self.mocks["log_error"].assert_called_once_with( + "Recording payment processor response failed! " + "merchant_reference: test_ref. " + "Exception: Test exception" + ) + + def _validate_response_success(self): + """Helper method to perform succeeding request.""" + response_data = { + "status": utils.SUCCESS_STATUS, + "merchant_reference": "test-1" + } + self.view.payment_processor = Mock() + self.view._basket = Mock(id=7, total_incl_tax=456.78, currency="FAKE") # pylint: disable=protected-access + self.view.validate_response(response_data) + return response_data + + def test_validate_response_success(self): + """Verify that validate_response calls the appropriate functions.""" + response_data = self._validate_response_success() + self.mocks["verify_signature"].assert_called_once_with( + self.view.payment_processor.response_sha_phrase, + self.view.payment_processor.sha_method, + response_data, + ) + self.mocks["verify_response_format"].assert_called_once_with(response_data) + + def test_validate_response_first_verify_signature_then_verify_response_format(self): + """Verify that validate_response calls the appropriate functions.""" + self._validate_response_success() + self.assertEqual(self.mocks["verify_signature"].call_count, 1) + self.assertEqual(self.mocks["verify_response_format"].call_count, 1) + for call in self.mocks["verify_signature"].mock_calls + self.mocks["verify_response_format"].mock_calls: + self.assertNotIn(call, self.mocks["verify_response_format"].mock_calls) + if call in self.mocks["verify_signature"].mock_calls: + break + + def test_validate_response_bad_signature(self): + """Verify that validate_response logs the exception when verify_signature fails.""" + response_data = { + "status": "99", + "merchant_reference": "test-1" + } + self.mocks["verify_signature"].side_effect = utils.PayFortBadSignatureException( + "Signature verification failed for response data: %s" % response_data, + ) + self.view.payment_processor = Mock() + with self.assertRaises(utils.PayFortBadSignatureException): + self.view.validate_response(response_data) + self.mocks["verify_signature"].assert_called_once_with( + self.view.payment_processor.response_sha_phrase, + self.view.payment_processor.sha_method, + response_data, + ) + self.mocks["verify_response_format"].assert_not_called() + self.mocks["log_error"].assert_called_once_with( + "Signature verification failed for response data: {'status': '99', 'merchant_reference': 'test-1'}" + ) + + def _assert_bad_format_error(self, response_data): + """Helper method to assert the bad format error.""" + self.mocks["verify_response_format"].side_effect = utils.PayFortException( + "Bad format for response data: missing mandatory field", + ) + self.view.payment_processor = Mock() + + with self.assertRaises(Http404): + self.view.validate_response(response_data) + + self.mocks["verify_signature"].assert_called_once() + self.mocks["verify_response_format"].assert_called_once_with(response_data) + + def test_validate_response_bad_format(self): + """Verify that validate_response logs the exception when verify_response_format fails.""" + self._assert_bad_format_error({ + "status": "99", + "merchant_reference": "test-1" + }) + self.mocks["log_error"].assert_called_once_with( + "Bad format for response data: missing mandatory field" + ) + + def test_validate_response_bad_format_but_successful_payment(self): + """Verify that validate_response logs the incident of having a bad payload for a successful payment.""" + self.view._basket = Mock() # pylint: disable=protected-access + self._assert_bad_format_error({ + "status": utils.SUCCESS_STATUS, + "merchant_reference": "test-1" + }) + self.assertEqual(self.mocks["log_error"].call_count, 2) + self.assertEqual( + self.mocks["log_error"].mock_calls[0][1], + ("Bad format for response data: missing mandatory field",) + ) + self.assertEqual( + self.mocks["log_error"].mock_calls[1][1], + ("Bad response format for a successful payment! reference: none, merchant_reference: test-1",) + ) + + def test_validate_response_no_basket(self): + """Verify that validate_response logs the error when the basket is not found.""" + response_data = { + "status": utils.SUCCESS_STATUS, + "merchant_reference": "test-1", + "fort_id": "fort-id", + } + self.view.payment_processor = Mock() + with self.assertRaises(Http404): + self.view.validate_response(response_data) + self.mocks["verify_signature"].assert_called_once_with( + self.view.payment_processor.response_sha_phrase, + self.view.payment_processor.sha_method, + response_data, + ) + self.mocks["verify_response_format"].assert_called_once_with(response_data) + self.mocks["log_error"].assert_called_once_with("Basket not found! merchant_reference: test-1") + + +class TestPayFortRedirectionResponseView(MockPatcherMixin, BaseTests): # pylint: disable=too-many-ancestors + """Test the PayFortRedirectionResponseView.""" + patching_config = { + "get_transaction_id": ("ecommerce_payfort.utils.get_transaction_id", { + "return_value": "the-transaction-id", + }), + "validate_response": ("ecommerce_payfort.views.PayFortRedirectionResponseView.validate_response", {}), + "save_response": ("ecommerce_payfort.views.PayFortRedirectionResponseView.save_payment_processor_response", { + "return_value": Mock(transaction_id="the-transaction-id"), + }), + "log_error": ("ecommerce_payfort.views.PayFortRedirectionResponseView.log_error", {}), + } + + def setUp(self): + """Set up the test.""" + super().setUp() + self.data = { + "status": utils.SUCCESS_STATUS, + "merchant_reference": "test-1", + "response_code": "00", + } + self.url = reverse("payfort:response") + + def test_retry_settings(self): + """Verify that the retry settings are reasonable.""" + self.assertTrue(9 < views.PayFortRedirectionResponseView.MAX_ATTEMPTS < 30) + self.assertTrue(1000 < views.PayFortRedirectionResponseView.WAIT_TIME < 10000) + + def test_post_success(self): + """Verify that the POST method works.""" + response = self.client.post(self.url, self.data) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "payfort_payment/wait_feedback.html") + self.data.update({ + "ecommerce_transaction_id": "the-transaction-id", + "ecommerce_error_url": reverse( + 'payfort:handle-internal-error', + args=["the-transaction-id"] + ), + "ecommerce_status_url": reverse("payfort:status"), + "ecommerce_max_attempts": views.PayFortRedirectionResponseView.MAX_ATTEMPTS, + "ecommerce_wait_time": views.PayFortRedirectionResponseView.WAIT_TIME, + }) + for key, value in self.data.items(): + self.assertEqual(response.context[key], value) + + def test_post_bad_signature(self): + """Verify that the POST method does not save the response when the signature is bad.""" + self.mocks["validate_response"].side_effect = utils.PayFortBadSignatureException( + f"Signature verification failed for response data: {self.data}", + ) + response = self.client.post(self.url, self.data) + self.assertEqual(response.status_code, 404) + self.mocks["save_response"].assert_not_called() + + def test_post_internal_error(self): + """Verify that the POST method saves the response when response validation fails.""" + self.mocks["validate_response"].side_effect = utils.PayFortException("something went wrong") + response = self.client.post(self.url, self.data) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, reverse( + 'payfort:handle-internal-error', + args=[utils.get_transaction_id(self.data)] + )) + self.mocks["save_response"].assert_called_once_with(self.data) + + def test_post_bad_data(self): + """Verify that the POST method saves the response when response validation fails because of bad format.""" + self.mocks["validate_response"].side_effect = Http404() + response = self.client.post(self.url, self.data) + self.assertEqual(response.status_code, 404) + self.mocks["save_response"].assert_called_once_with(self.data) + + def test_post_status_failed(self): + """Verify that the POST method logs an error and redirect to payment_error when the payment is failed.""" + self.data["status"] = "99" + self.data["response_code"] = "99" + response = self.client.post(self.url, self.data) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, reverse("payment_error")) + self.mocks["save_response"].assert_called_once_with(self.data) + self.mocks["log_error"].assert_called_once_with( + "Payfort payment failed! merchant_reference: test-1. response_code: 99" + ) + + +class TestPayFortStatusView(MockPatcherMixin, BaseTests): # pylint: disable=too-many-ancestors + """Test the PayFortStatusView.""" + patching_config = { + "basket": ("ecommerce_payfort.views.PayFortStatusView.basket", { + "return_value": None, + "new_callable": PropertyMock, + }), + } + + def setUp(self): + """Set up the test.""" + super().setUp() + self.url = reverse("payfort:status") + + def test_post_invalid_basket(self): + """Verify that the POST method returns 404 when the basket is not found.""" + self.mocks["basket"].return_value = None + response = self.client.post(self.url) + self.assertEqual(response.status_code, 404) + + def test_post_frozen_basket(self): + """Verify that the POST method returns 204 when the basket is still frozen.""" + self.mocks["basket"].return_value = Mock(status=views.Basket.FROZEN) + response = self.client.post(self.url) + self.assertEqual(response.status_code, 204) + + def test_post_not_frozen_not_submitted_basket(self): + """Verify that the POST method returns 404 when the basket is neither frozen nor submitted.""" + self.mocks["basket"].return_value = Mock(status="something-else") + response = self.client.post(self.url) + self.assertEqual(response.status_code, 404) + + def test_post_submitted_basket(self): + """Verify that the POST method returns 200 and the receipt_url when the basket is submitted.""" + self.mocks["basket"].return_value = Mock(status=views.Basket.SUBMITTED) + with patch("ecommerce_payfort.views.get_receipt_page_url", return_value="a-url-to-the-receipt"): + response = self.client.post(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.content.decode("utf-8")), { + "receipt_url": "a-url-to-the-receipt", + }) + + +@ddt.ddt +class TestPayFortFeedbackView(MockPatcherMixin, BaseTests, unittest.TestCase): # pylint: disable=too-many-ancestors + """Test the PayFortFeedbackView.""" + patching_config = { + "validate_response": ("ecommerce_payfort.views.PayFortFeedbackView.validate_response", {}), + "save_response": ("ecommerce_payfort.views.PayFortFeedbackView.save_payment_processor_response", { + "return_value": Mock(id=18, transaction_id="the-transaction-id"), + }), + "log_error": ("ecommerce_payfort.views.PayFortFeedbackView.log_error", {}), + "handle_payment": ("ecommerce_payfort.views.PayFortFeedbackView.handle_payment", {}), + "create_order": ("ecommerce_payfort.views.PayFortFeedbackView.create_order", {}), + "basket": ("ecommerce_payfort.views.PayFortFeedbackView.basket", { + "return_value": None, + "new_callable": PropertyMock, + }), + } + + def setUp(self): + """Set up the test.""" + super().setUp() + self.url = reverse("payfort:feedback") + self.data = { + "status": utils.SUCCESS_STATUS, + "merchant_reference": "test-1", + "response_code": "00", + } + + def test_post_successful_payment(self): + """Verify that the POST method works.""" + class IsWSGIRequest: # pylint: disable=too-few-public-methods + """Helper class to check if an object is a WSGIRequest.""" + def __eq__(self, other): + """Check if the object is a WSGIRequest.""" + return isinstance(other, WSGIRequest) + + basket = Mock() + self.mocks["basket"].return_value = basket + response = self.client.post(self.url, self.data) + self.assertEqual(response.status_code, 200) + self.mocks["save_response"].assert_called_once_with(self.data) + self.mocks["handle_payment"].assert_called_once_with( + {'status': '14', 'merchant_reference': 'test-1', 'response_code': '00'}, + basket + ) + self.mocks["create_order"].assert_called_once_with(IsWSGIRequest(), basket) + + def _verify_save_with_200_response(self, response): + """Helper method to verify the save_response is called and a 200 is returned.""" + self.assertEqual(response.status_code, 200) + self.mocks["save_response"].assert_called_once_with(self.data) + self.mocks["handle_payment"].assert_not_called() + self.mocks["create_order"].assert_not_called() + + def test_post_failed_payment(self): + """Verify that the POST method works.""" + self.data["status"] = "99" + response = self.client.post(self.url, self.data) + self._verify_save_with_200_response(response) + self.mocks["log_error"].assert_called_once_with( + "Payfort payment failed! merchant_reference: test-1. response_code: 00" + ) + + def test_post_bad_signature(self): + """Verify that the POST method does not save the response when the signature is bad.""" + self.mocks["validate_response"].side_effect = utils.PayFortBadSignatureException( + f"Signature verification failed for response data: {self.data}", + ) + response = self.client.post(self.url, self.data) + self.assertEqual(response.status_code, 404) + self.mocks["save_response"].assert_not_called() + + @ddt.data(utils.PayFortException, Http404) + def test_post_other_errors(self, effect): + """ + Verify that the POST method saves the response when response validation fails for any reason other + than a bad signature. + """ + self.mocks["validate_response"].side_effect = effect + response = self.client.post(self.url, self.data) + self.assertEqual(response.status_code, 404) + self.mocks["save_response"].assert_called_once_with(self.data) + + def test_post_process_exception(self): + """Verify that the POST method logs the exception when handle_payment fails.""" + self.mocks["basket"].return_value = Mock(id=7) + self.mocks["handle_payment"].side_effect = Exception("Test exception") + with patch("ecommerce_payfort.views.logger.exception") as mock_log_error: + response = self.client.post(self.url, self.data) + self.assertEqual(response.status_code, 422) + mock_log_error.assert_called_once_with( + "Processing payment for basket [%d] failed! The payment was successfully processed by PayFort. " + "Response was recorded in entry no. (%d: %s). Exception: %s: %s", + 7, 18, "the-transaction-id", "Exception", "Test exception" + ) + + def test_already_processed_payment(self): + """Verify that the POST method returns 200 when the payment is already processed.""" + self.mocks["basket"].return_value = Mock(status=views.Basket.SUBMITTED) + response = self.client.post(self.url, self.data) + self._verify_save_with_200_response(response) + + def test_notification_view(self): + """Verify that the notification PayFortNotificationView view works is derived from feedback view.""" + self.assertTrue(issubclass(views.PayFortNotificationView, views.PayFortFeedbackView)) diff --git a/ecommerce_payfort/tests/unit/__init__.py b/ecommerce_payfort/tests/unit/__init__.py deleted file mode 100644 index 520bb50..0000000 --- a/ecommerce_payfort/tests/unit/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Unit tests without external dependencies on ecommerce or payfort. -""" diff --git a/ecommerce_payfort/tests/unit/test_payfort_utils.py b/ecommerce_payfort/tests/unit/test_payfort_utils.py deleted file mode 100644 index 8978e03..0000000 --- a/ecommerce_payfort/tests/unit/test_payfort_utils.py +++ /dev/null @@ -1,4 +0,0 @@ - - -def test_utils(): - pass diff --git a/ecommerce_payfort/tests/urls.py b/ecommerce_payfort/tests/urls.py new file mode 100644 index 0000000..e39742f --- /dev/null +++ b/ecommerce_payfort/tests/urls.py @@ -0,0 +1,18 @@ +"""URLS for testing the ecommerce_payfort app.""" + +from django.urls import include +from django.conf.urls import url + +from django.http import JsonResponse + + +def dummy_view(request): + return JsonResponse({'message': 'This is a dummy view for testing purposes.'}) + + +# include the original urls +urlpatterns = [ + url(r'^payfort/', include('ecommerce_payfort.urls')), + url(r'^login/', dummy_view), + url(r'', include('ecommerce.urls')), +] diff --git a/ecommerce_payfort/urls.py b/ecommerce_payfort/urls.py index a4a9078..56d47cf 100644 --- a/ecommerce_payfort/urls.py +++ b/ecommerce_payfort/urls.py @@ -1,16 +1,31 @@ -""" -Defines the URL routes for the payfort app. -""" -from django.conf.urls import url +"""Defines the URL routes for the payfort app.""" +from django.urls import re_path -from .views import PayFortPaymentPageView, PayFortResponseView +from .views import ( + PayFortFeedbackView, + PayFortPaymentHandleFormatErrorView, + PayFortPaymentHandleInternalErrorView, + PayFortPaymentRedirectView, + PayFortRedirectionResponseView, + PayFortStatusView, +) + +app_name = 'payfort' urlpatterns = [ - url(r'^payment/payfort/pay/$', PayFortPaymentPageView.as_view(), name='payment-form'), - url(r'^payment/payfort/submit/$', PayFortResponseView.as_view(), name='submit'), - url( - r'^payment/payfort/status/(?P.+)/$', - PayFortResponseView.as_view(), - name='status-check' + re_path(r'^pay/$', PayFortPaymentRedirectView.as_view(), name='form'), + re_path(r'^response/$', PayFortRedirectionResponseView.as_view(), name='response'), + re_path(r'^feedback/$', PayFortFeedbackView.as_view(), name='feedback'), + re_path(r'^status/$', PayFortStatusView.as_view(), name='status'), + + re_path( + r'^handle_internal_error/(.+)/$', + PayFortPaymentHandleInternalErrorView.as_view(), + name='handle-internal-error' + ), + re_path( + r'^handle_format_error/(.+)/$', + PayFortPaymentHandleFormatErrorView.as_view(), + name='handle-format-error' ), ] diff --git a/ecommerce_payfort/utils.py b/ecommerce_payfort/utils.py new file mode 100644 index 0000000..0c1f85d --- /dev/null +++ b/ecommerce_payfort/utils.py @@ -0,0 +1,341 @@ +"""Utility functions for the Payfort payment gateway.""" +from __future__ import annotations + +import hashlib +import re +from typing import Any + +from oscar.apps.payment.exceptions import GatewayError +from oscar.core.loading import get_model + +Basket = get_model("basket", "Basket") + +MANDATORY_RESPONSE_FIELDS = [ + "merchant_reference", + "command", + "merchant_identifier", + "amount", + "currency", + "response_code", + "signature", + "status", +] +MAX_ORDER_DESCRIPTION_LENGTH = 150 +SUCCESS_STATUS = "14" +SUPPORTED_SHA_METHODS = { + "SHA-256": hashlib.sha256, + "SHA-512": hashlib.sha512, +} +VALID_CURRENCY = "SAR" +VALID_PATTERNS = { + "order_description": r"[^A-Za-z0-9 '/\._\-#:$]", + "customer_name": r"[^A-Za-z _\\/\-\.']", +} + + +class PayFortException(GatewayError): + """PayFort exception.""" + + +class PayFortBadSignatureException(PayFortException): + """PayFort bad signature exception.""" + + +def sanitize_text( + text_to_sanitize: str, valid_pattern: str, max_length: int | None = None, replacement: str = "_" +) -> str: + """ + Sanitize the text by replacing invalid characters with the replacement character. + + @param text_to_sanitize: The text to sanitize + @param valid_pattern: The valid pattern to match the text against + @param max_length: The maximum length of the sanitized text + @param replacement: The replacement character for invalid characters + @return: The sanitized text + """ + if (valid_pattern or "") == "": + return "" + + sanitized = re.sub(valid_pattern, replacement, text_to_sanitize) + if max_length is None or max_length <= 0: + return sanitized + + if len(sanitized) > max_length and r'\.' in valid_pattern: + return sanitized[:max_length - 3] + "..." + return sanitized[:max_length] + + +def verify_param(param: Any, param_name: str, required_type: Any): + """ + Verify a parameter type + + @param param: The parameter to verify + @param param_name: The name of the parameter to be used in the exception message + @param required_type: The required type of the parameter + """ + if param is None or not isinstance(param, required_type): + raise PayFortException( + f"verify_param failed: {param_name} is required and must be " + f"({required_type.__name__}), but got ({type(param).__name__})" + ) + + +def get_amount(basket: Basket) -> int: + """ + Return the amount for the given basket in the ISO 4217 currency format for SAR. + + @param basket: The basket + @return: The amount + """ + verify_param(basket, "basket", Basket) + + return int(round(basket.total_incl_tax * 100, 0)) + + +def get_currency(basket: Basket) -> str: + """ + Return the currency for the given basket. + + @param basket: The basket + @return: The currency + """ + verify_param(basket, "basket", Basket) + + for line in basket.all_lines(): + if line.price_currency and line.price_currency != VALID_CURRENCY: + raise PayFortException(f"Currency not supported: {line.price_currency}") + + return VALID_CURRENCY + + +def get_customer_email(basket: Basket) -> str: + """ + Return the customer email for the given basket. + + @param basket: The basket + @return: The customer email + """ + verify_param(basket, "basket", Basket) + + return basket.owner.email + + +def get_customer_name(basket: Basket) -> str: + """ + Return the customer name for the given basket. + + @param basket: The basket + @return: The customer name + """ + verify_param(basket, "basket", Basket) + + return sanitize_text( + basket.owner.get_full_name() or "Name not set", + VALID_PATTERNS["customer_name"], + max_length=50, + ) + + +def get_language(request: Any) -> str: + """ + Return the language from the request. + + @param request: The request + @return: The language + """ + if request is None or not hasattr(request, "LANGUAGE_CODE"): + return "en" + result = request.LANGUAGE_CODE.split("-")[0].lower() + + return result if result in ("en", "ar") else "en" + + +def get_merchant_reference(site_id: int, basket: Basket) -> str: + """ + Return the merchant reference for the given basket. + + @param site_id: The site ID + @param basket: The basket + @return: The merchant reference + """ + verify_param(site_id, "site_id", int) + verify_param(basket, "basket", Basket) + + return f"{site_id}-{basket.owner_id}-{basket.id}" + + +def get_order_description(basket: Basket) -> str: + """ + Return the order description for the given basket. + + @param basket: The basket + @return: The order description + """ + def _get_course_id(product: Any) -> str | None: + """Return the course ID.""" + if product.course: + return product.course.id + if product.parent and product.parent.course: + return product.parent.course.id + + return None + + def _get_product_title(product: Any) -> str | None: + """Return the product title.""" + result = (product.title or "").strip() + if result != "": + return result + + if not product.parent: + return None + + return (product.parent.title or "").strip() + + def _get_product_description(product: Any) -> str: + """Return the product description.""" + result = _get_course_id(product) + if result is None: + result = _get_product_title(product) + + return result or "-" + + verify_param(basket, "basket", Basket) + + description = "" + max_index = len(basket.all_lines()) - 1 + for index, line in enumerate(basket.all_lines()): + description += f"{line.quantity} X {_get_product_description(line.product).replace(';', '_') or '-'}" + if index < max_index: + description += " // " + + return sanitize_text( + description, + VALID_PATTERNS["order_description"], + max_length=MAX_ORDER_DESCRIPTION_LENGTH + ) + + +def get_signature(sha_phrase: str, sha_method: str, transaction_parameters: dict) -> str: + """ + Return the signature for the given transaction parameters. + + @param sha_phrase: The SHA phrase + @param sha_method: The SHA method + @param transaction_parameters: The transaction parameters + @return: The calculated signature + """ + verify_param(sha_phrase, "sha_phrase", str) + verify_param(sha_method, "sha_method", str) + verify_param(transaction_parameters, "transaction_parameters", dict) + + sha_method_fnc = SUPPORTED_SHA_METHODS.get(sha_method) + if sha_method_fnc is None: + raise PayFortException(f"Unsupported SHA method: {sha_method}") + + sorted_keys = sorted(transaction_parameters, key=lambda arg: arg.lower()) + sorted_dict = {key: transaction_parameters[key] for key in sorted_keys} + + result_string = f"{sha_phrase}{''.join(f'{key}={value}' for key, value in sorted_dict.items())}{sha_phrase}" + + return sha_method_fnc(result_string.encode()).hexdigest() + + +def get_transaction_id(response_data: dict) -> str: + """ + Return the transaction ID from the response data. + + @param response_data: The response data + @return: The transaction ID + """ + verify_param(response_data, "response_data", dict) + + return f"{response_data.get('eci') or 'none'}-{response_data.get('fort_id') or 'none'}" + + +def verify_response_format(response_data): + """Verify the format of the response from PayFort.""" + for field in MANDATORY_RESPONSE_FIELDS: + if field not in response_data: + raise PayFortException(f"Missing field in response: {field}") + if not isinstance(response_data[field], str): + raise PayFortException(( + f"Invalid field type in response: {field}. " + f"Should be , but got <{type(response_data[field]).__name__}>" + )) + + try: + amount = int(response_data["amount"]) + if amount < 0 or response_data["amount"] != str(amount): + raise ValueError + except ValueError as exc: + raise PayFortException( + f"Invalid amount in response (not a positive integer): {response_data['amount']}" + ) from exc + + if response_data["currency"] != VALID_CURRENCY: + raise PayFortException(f"Invalid currency in response: {response_data['currency']}") + + if response_data["command"] != "PURCHASE": + raise PayFortException(f"Invalid command in response: {response_data['command']}") + + if re.fullmatch(r"\d+-\d+-\d+", response_data["merchant_reference"]) is None: + raise PayFortException( + f"Invalid merchant_reference in response: {response_data['merchant_reference']}" + ) + + if ( + (response_data.get("eci") is None or response_data.get("fort_id") is None) and + response_data['status'] == SUCCESS_STATUS + ): + raise PayFortException( + f"Unexpected successful payment that lacks eci or fort_id: {response_data['merchant_reference']}" + ) + + +def verify_signature(sha_phrase: str, sha_method: str, data: dict): + """ + Verify the data signature. + + @param sha_phrase: The SHA phrase + @param sha_method: The SHA method + @param data: The response data + """ + verify_param(data, "response_data", dict) + + sha_method_fnc = SUPPORTED_SHA_METHODS.get(sha_method) + if sha_method_fnc is None: + raise PayFortException(f"Unsupported SHA method: {sha_method}") + + data = data.copy() + signature = data.pop("signature", None) + if signature is None: + raise PayFortBadSignatureException("Signature not found!") + + expected_signature = get_signature( + sha_phrase, + sha_method, + data, + ) + if signature != expected_signature: + raise PayFortBadSignatureException( + f"Response signature mismatch. merchant_reference: {data.get('merchant_reference', 'none')}" + ) + + +def get_ip_address(request: Any) -> str: + """ + Return the customer IP address from the request. + + @param request: The request + @return: The customer IP address + """ + if request is None: + return "" + + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + ip_address = x_forwarded_for.split(",")[0] + else: + ip_address = request.META.get("REMOTE_ADDR") + + return (ip_address or "").strip() diff --git a/ecommerce_payfort/views.py b/ecommerce_payfort/views.py index c340351..fae8f7b 100644 --- a/ecommerce_payfort/views.py +++ b/ecommerce_payfort/views.py @@ -1,50 +1,274 @@ -""" -Views related to the PayFort payment processor. -""" - +"""Views related to the PayFort payment processor.""" import logging -from django.db import transaction -from django.shortcuts import render +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import ObjectDoesNotExist +from django.db.transaction import atomic, non_atomic_requests +from django.http import Http404, HttpResponse, JsonResponse +from django.shortcuts import redirect, render +from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from django.views.generic import View -from oscar.core.loading import get_class, get_model - +from django.views.generic import TemplateView, View from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin +from ecommerce.extensions.checkout.utils import get_receipt_page_url +from oscar.apps.partner import strategy +from oscar.core.loading import get_class, get_model -from .processors import PayFort +from ecommerce_payfort import utils +from ecommerce_payfort.processors import PayFort logger = logging.getLogger(__name__) -Applicator = get_class('offer.applicator', 'Applicator') -Basket = get_model('basket', 'Basket') -OrderNumberGenerator = get_class('order.utils', 'OrderNumberGenerator') +Applicator = get_class("offer.applicator", "Applicator") +Basket = get_model("basket", "Basket") +OrderNumberGenerator = get_class("order.utils", "OrderNumberGenerator") -class PayFortPaymentPageView(View): - """ - Render the template which loads the PayFort payment form via JavaScript - """ - template_name = 'payment/payfort.html' +class PayFortPaymentRedirectView(LoginRequiredMixin, TemplateView): + """Render the template which loads the PayFort payment form via JavaScript""" + template_name = "payfort_payment/form.html" def post(self, request): - """ - Handles the POST request. - """ - return render(request, self.template_name, request.POST.dict()) - + """Handles the POST request.""" + return render(request=request, template_name=self.template_name, context=request.POST.dict()) -class PayFortResponseView(EdxOrderPlacementMixin, View): - """ - Handle the response from PayFort after processing the payment. - """ - @property - def payment_processor(self): - return PayFort(self.request.site) +class PayFortCallBaseView(EdxOrderPlacementMixin, View): + """Base class for the PayFort views.""" + def __init__(self, *args, **kwargs): + """Initialize the PayFortCallBaseView.""" + super().__init__(*args, **kwargs) + self.payment_processor = None + self.request = None + self._basket = None - @method_decorator(transaction.non_atomic_requests) + @method_decorator(non_atomic_requests) @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): - return super(PayFortResponseView, self).dispatch(request, *args, **kwargs) + """Dispatch the request to the appropriate handler.""" + return super().dispatch(request, *args, **kwargs) + + @property + def basket(self): + """Retrieve the basket from the database.""" + if self._basket is not None: + return self._basket + + if not self.request: + return None + + merchant_reference = self.request.POST.get("merchant_reference", "") + + try: + basket_id = int(merchant_reference.split('-')[-1]) + basket = Basket.objects.get(id=basket_id) + basket.strategy = strategy.Default() + Applicator().apply(basket, basket.owner, self.request) + + self._basket = basket + except (ValueError, ObjectDoesNotExist): + return None + + return self._basket + + def log_error(self, message): + """Log the error message.""" + logger.error("%s: %s", self.__class__.__name__, message) + + def save_payment_processor_response(self, response_data): + """Save the payment processor response to the database.""" + try: + return self.payment_processor.record_processor_response( + response={ + "view": self.__class__.__name__, + "response": response_data + }, + transaction_id=utils.get_transaction_id(response_data), + basket=self.basket + ) + except Exception as exc: + self.log_error( + f"Recording payment processor response failed! " + f"merchant_reference: {response_data.get('merchant_reference', 'none')}. " + f"Exception: {str(exc)}" + ) + raise Http404 from exc + + def validate_response(self, response_data): + """Validate the response from PayFort.""" + try: + utils.verify_signature( + self.payment_processor.response_sha_phrase, + self.payment_processor.sha_method, + response_data, + ) + except utils.PayFortBadSignatureException as exc: + self.log_error(str(exc)) + raise + + success = response_data.get("status", "") == utils.SUCCESS_STATUS + + try: + utils.verify_response_format(response_data) + except utils.PayFortException as exc: + self.log_error(str(exc)) + if success and self.basket: + reference = response_data.get("fort_id", "none") + self.log_error( + f"Bad response format for a successful payment! reference: {reference}, " + f"merchant_reference: {response_data.get('merchant_reference', 'none')}" + ) + raise Http404 from exc + + if not self.basket: + self.log_error( + f"Basket not found! merchant_reference: {response_data['merchant_reference']}" + ) + raise Http404() + + +class PayFortRedirectionResponseView(PayFortCallBaseView): + """Handle the response from PayFort sent to customer after processing the payment.""" + template_name = "payfort_payment/wait_feedback.html" + MAX_ATTEMPTS = 24 + WAIT_TIME = 5000 + + def post(self, request): + """Handle the POST request from PayFort after processing the payment.""" + data = request.POST.dict() + self.payment_processor = PayFort(request.site) + self.request = request + + try: + self.validate_response(data) + except utils.PayFortBadSignatureException as exc: + raise Http404 from exc + except utils.PayFortException: + self.save_payment_processor_response(data) + return redirect(reverse( + 'payfort:handle-internal-error', + args=[utils.get_transaction_id(data)] + )) + except Http404: + self.save_payment_processor_response(data) + raise + + payment_processor_response = self.save_payment_processor_response(data) + if data["status"] == utils.SUCCESS_STATUS: + data["ecommerce_transaction_id"] = payment_processor_response.transaction_id + data["ecommerce_error_url"] = reverse( + 'payfort:handle-internal-error', + args=[payment_processor_response.transaction_id] + ) + data["ecommerce_status_url"] = reverse("payfort:status") + data["ecommerce_max_attempts"] = self.MAX_ATTEMPTS + data["ecommerce_wait_time"] = self.WAIT_TIME + return render(request=request, template_name=self.template_name, context=data) + + self.log_error( + f"Payfort payment failed! merchant_reference: {data['merchant_reference']}. " + f"response_code: {data['response_code']}" + ) + return redirect(reverse("payment_error")) + + +class PayFortStatusView(PayFortCallBaseView): + """Handle the status request from PayFort.""" + def post(self, request): + """Handle the POST request from PayFort.""" + if not self.basket: + return HttpResponse(status=404) + + if self.basket.status == Basket.FROZEN: + return HttpResponse(status=204) + + if self.basket.status == Basket.SUBMITTED: + return JsonResponse( + { + "receipt_url": get_receipt_page_url( + request=request, + site_configuration=self.basket.site.siteconfiguration, + order_number=self.basket.order_number, + ), + }, + status=200, + ) + + return HttpResponse(status=404) + + +class PayFortFeedbackView(PayFortCallBaseView): + """Handle the response from PayFort sent to customer after processing the payment.""" + def post(self, request): + """Handle the POST request from PayFort after processing the payment.""" + data = request.POST.dict() + self.payment_processor = PayFort(request.site) + self.request = request + + try: + self.validate_response(data) + except utils.PayFortBadSignatureException as exc: + raise Http404 from exc + except (Http404, utils.PayFortException) as exc: + self.save_payment_processor_response(data) + raise Http404 from exc + + payment_processor_response = self.save_payment_processor_response(data) + if data["status"] != utils.SUCCESS_STATUS: + self.log_error( + f"Payfort payment failed! merchant_reference: {data['merchant_reference']}. " + f"response_code: {data['response_code']}" + ) + return HttpResponse(status=200) + + if self.basket.status == Basket.SUBMITTED: + return HttpResponse(status=200) + + try: + with atomic(): + self.handle_payment(data, self.basket) + self.create_order(request, self.basket) + except Exception as exc: # pylint:disable=broad-except + logger.exception( + "Processing payment for basket [%d] failed! " + "The payment was successfully processed by PayFort. Response was recorded in entry no. " + "(%d: %s). " + "Exception: %s: %s", + self.basket.id, + payment_processor_response.id, + payment_processor_response.transaction_id, + exc.__class__.__name__, + str(exc), + ) + return HttpResponse(status=422) + + return HttpResponse(status=200) + + +class PayFortNotificationView(PayFortFeedbackView): + """Handle the notification from PayFort.""" + + +class PayFortPaymentHandleInternalErrorView(TemplateView): + """Render the template that shows the error message to the user when the payment handling is failed.""" + template_name = "payfort_payment/handle_internal_error.html" + + def get(self, request, *args, **kwargs): + """Handles the GET request.""" + context = { + "merchant_reference": args[0], + } + return render(request, self.template_name, context) + + +class PayFortPaymentHandleFormatErrorView(TemplateView): + """Render the template that shows the error message to the user when the payment response is in wrong format.""" + template_name = "payfort_payment/handle_format_error.html" + + def get(self, request, *args, **kwargs): + """Handles the GET request.""" + context = { + "reference": args[0], + } + return render(request, self.template_name, context) diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..ff1a517 --- /dev/null +++ b/pylintrc @@ -0,0 +1,416 @@ +# *************************** +# ** DO NOT EDIT THIS FILE ** +# *************************** +# +# This file was generated by edx-lint: https://github.com/edx/edx-lint +# +# If you want to change this file, you have two choices, depending on whether +# you want to make a local change that applies only to this repo, or whether +# you want to make a central change that applies to all repos using edx-lint. +# +# Note: If your pylintrc file is simply out-of-date relative to the latest +# pylintrc in edx-lint, ensure you have the latest edx-lint installed +# and then follow the steps for a "LOCAL CHANGE". +# +# LOCAL CHANGE: +# +# 1. Edit the local pylintrc_tweaks file to add changes just to this +# repo's file. +# +# 2. Run: +# +# $ edx_lint write pylintrc +# +# 3. This will modify the local file. Submit a pull request to get it +# checked in so that others will benefit. +# +# +# CENTRAL CHANGE: +# +# 1. Edit the pylintrc file in the edx-lint repo at +# https://github.com/edx/edx-lint/blob/master/edx_lint/files/pylintrc +# +# 2. install the updated version of edx-lint (in edx-lint): +# +# $ pip install . +# +# 3. Run (in edx-lint): +# +# $ edx_lint write pylintrc +# +# 4. Make a new version of edx_lint, submit and review a pull request with the +# pylintrc update, and after merging, update the edx-lint version and +# publish the new version. +# +# 5. In your local repo, install the newer version of edx-lint. +# +# 6. Run: +# +# $ edx_lint write pylintrc +# +# 7. This will modify the local file. Submit a pull request to get it +# checked in so that others will benefit. +# +# +# +# +# +# STAY AWAY FROM THIS FILE! +# +# +# +# +# +# SERIOUSLY. +# +# ------------------------------ +# Generated by edx-lint version: 5.2.0 +# ------------------------------ +[MASTER] +ignore = migrations +persistent = yes +load-plugins = edx_lint.pylint,pylint_django,pylint_celery + +[MESSAGES CONTROL] +enable = + blacklisted-name, + line-too-long, + + abstract-class-instantiated, + abstract-method, + access-member-before-definition, + anomalous-backslash-in-string, + anomalous-unicode-escape-in-string, + arguments-differ, + assert-on-tuple, + assigning-non-slot, + assignment-from-no-return, + assignment-from-none, + attribute-defined-outside-init, + bad-except-order, + bad-format-character, + bad-format-string-key, + bad-format-string, + bad-open-mode, + bad-reversed-sequence, + bad-staticmethod-argument, + bad-str-strip-call, + bad-super-call, + binary-op-exception, + boolean-datetime, + catching-non-exception, + cell-var-from-loop, + confusing-with-statement, + continue-in-finally, + cyclical-import, + dangerous-default-value, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + duplicate-argument-name, + duplicate-bases, + duplicate-except, + duplicate-key, + eq-without-hash, + exception-escape, + exception-message-attribute, + expression-not-assigned, + filter-builtin-not-iterating, + format-combined-specification, + format-needs-mapping, + function-redefined, + global-variable-undefined, + import-error, + import-self, + inconsistent-mro, + indexing-exception, + inherit-non-class, + init-is-generator, + invalid-all-object, + invalid-encoded-data, + invalid-format-index, + invalid-length-returned, + invalid-sequence-index, + invalid-slice-index, + invalid-slots-object, + invalid-slots, + invalid-str-codec, + invalid-unary-operand-type, + logging-too-few-args, + logging-too-many-args, + logging-unsupported-format, + lost-exception, + map-builtin-not-iterating, + method-hidden, + misplaced-bare-raise, + misplaced-future, + missing-format-argument-key, + missing-format-attribute, + missing-format-string-key, + missing-super-argument, + mixed-fomat-string, + model-unicode-not-callable, + no-member, + no-method-argument, + no-name-in-module, + no-self-argument, + no-value-for-parameter, + non-iterator-returned, + non-parent-method-called, + nonexistent-operator, + nonimplemented-raised, + nonstandard-exception, + not-a-mapping, + not-an-iterable, + not-callable, + not-context-manager, + not-in-loop, + pointless-statement, + pointless-string-statement, + property-on-old-class, + raising-bad-type, + raising-non-exception, + raising-string, + range-builtin-not-iterating, + redefined-builtin, + redefined-in-handler, + redefined-outer-name, + redefined-variable-type, + redundant-keyword-arg, + relative-import, + repeated-keyword, + return-arg-in-generator, + return-in-init, + return-outside-function, + signature-differs, + slots-on-old-class, + super-init-not-called, + super-method-not-called, + super-on-old-class, + syntax-error, + sys-max-int, + test-inherits-tests, + too-few-format-args, + too-many-format-args, + too-many-function-args, + translation-of-non-string, + truncated-format-string, + unbalance-tuple-unpacking, + undefined-all-variable, + undefined-loop-variable, + undefined-variable, + unexpected-keyword-arg, + unexpected-special-method-signature, + unpacking-non-sequence, + unreachable, + unsubscriptable-object, + unsupported-binary-operation, + unsupported-membership-test, + unused-format-string-argument, + unused-format-string-key, + used-before-assignment, + using-constant-test, + yield-outside-function, + zip-builtin-not-iterating, + + astroid-error, + django-not-available-placeholder, + django-not-available, + fatal, + method-check-failed, + parse-error, + raw-checker-failed, + + empty-docstring, + invalid-characters-in-docstring, + missing-docstring, + wrong-spelling-in-comment, + wrong-spelling-in-docstring, + + unused-argument, + unused-import, + unused-variable, + + eval-used, + exec-used, + + bad-classmethod-argument, + bad-mcs-classmethod-argument, + bad-mcs-method-argument, + bad-whitespace, + bare-except, + broad-except, + consider-iterating-dictionary, + consider-using-enumerate, + global-at-module-level, + global-variable-not-assigned, + literal-used-as-attribute, + logging-format-interpolation, + logging-not-lazy, + metaclass-assignment, + model-has-unicode, + model-missing-unicode, + model-no-explicit-unicode, + multiple-imports, + multiple-statements, + no-classmethod-decorator, + no-staticmethod-decorator, + old-raise-syntax, + old-style-class, + protected-access, + redundant-unittest-assert, + reimported, + simplifiable-if-statement, + simplifiable-range, + singleton-comparison, + superfluous-parens, + unidiomatic-typecheck, + unnecessary-lambda, + unnecessary-pass, + unnecessary-semicolon, + unneeded-not, + useless-else-on-loop, + wrong-assert-type, + + deprecated-method, + deprecated-module, + + too-many-boolean-expressions, + too-many-nested-blocks, + too-many-statements, + + wildcard-import, + wrong-import-order, + wrong-import-position, + + missing-final-newline, + mixed-indentation, + mixed-line-endings, + trailing-newlines, + trailing-whitespace, + unexpected-line-ending-format, + + bad-inline-option, + bad-option-value, + deprecated-pragma, + unrecognized-inline-option, + useless-suppression, + + cmp-method, + coerce-method, + delslice-method, + dict-iter-method, + dict-view-method, + div-method, + getslice-method, + hex-method, + idiv-method, + next-method-called, + next-method-defined, + nonzero-method, + oct-method, + rdiv-method, + setslice-method, + using-cmp-argument, +disable = + django-not-configured, + +[REPORTS] +output-format = text +files-output = no +reports = no +score = no + +[BASIC] +bad-functions = map,filter,apply,input +module-rgx = (([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$ +class-rgx = [A-Z_][a-zA-Z0-9]+$ +function-rgx = ([a-z_][a-z0-9_]{2,40}|test_[a-z0-9_]+)$ +method-rgx = ([a-z_][a-z0-9_]{2,40}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ +attr-rgx = [a-z_][a-z0-9_]{2,30}$ +argument-rgx = [a-z_][a-z0-9_]{2,30}$ +variable-rgx = [a-z_][a-z0-9_]{2,30}$ +class-attribute-rgx = ([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ +inlinevar-rgx = [A-Za-z_][A-Za-z0-9_]*$ +good-names = f,i,j,k,db,ex,Run,_,__ +bad-names = foo,bar,baz,toto,tutu,tata +no-docstring-rgx = __.*__$|setUp$|setUpClass$|tearDown$|tearDownClass$|Meta$ +docstring-min-length = 5 + +[FORMAT] +max-line-length = 120 +ignore-long-lines = ^\s*(# )?((?)|(\.\. \w+: .*))$ +single-line-if-stmt = no +no-space-check = trailing-comma,dict-separator +max-module-lines = 1000 +indent-string = ' ' + +[MISCELLANEOUS] +notes = FIXME,XXX,TODO + +[SIMILARITIES] +min-similarity-lines = 4 +ignore-comments = yes +ignore-docstrings = yes +ignore-imports = no + +[TYPECHECK] +ignore-mixin-members = yes +ignored-classes = SQLObject +unsafe-load-any-extension = yes +generated-members = + REQUEST, + acl_users, + aq_parent, + objects, + DoesNotExist, + can_read, + can_write, + get_url, + size, + content, + status_code, + create, + build, + fields, + tag, + org, + course, + category, + name, + revision, + _meta, + +[VARIABLES] +init-import = no +dummy-variables-rgx = _|dummy|unused|.*_unused +additional-builtins = + +[CLASSES] +defining-attr-methods = __init__,__new__,setUp +valid-classmethod-first-arg = cls +valid-metaclass-classmethod-first-arg = mcs + +[DESIGN] +max-args = 5 +ignored-argument-names = _.* +max-locals = 15 +max-returns = 6 +max-branches = 12 +max-statements = 50 +max-parents = 7 +max-attributes = 7 +min-public-methods = 2 +max-public-methods = 20 + +[IMPORTS] +deprecated-modules = regsub,TERMIOS,Bastion,rexec +import-graph = +ext-import-graph = +int-import-graph = + +[EXCEPTIONS] +overgeneral-exceptions = Exception + +# cbea5b1b9ea1d0ed661dfe52295fac4907925a9b diff --git a/pylintrc_tweaks b/pylintrc_tweaks new file mode 100644 index 0000000..ce0d638 --- /dev/null +++ b/pylintrc_tweaks @@ -0,0 +1,12 @@ +# pylintrc tweaks for use with edx_lint. +[MASTER] +ignore = migrations +load-plugins = edx_lint.pylint,pylint_django,pylint_celery + +[MESSAGES CONTROL] +disable = + django-not-configured, + +[BASIC] +# Removing test_.+ from no-docstring-rgx to allow for test methods to be documented. +no-docstring-rgx = __.*__$|setUp$|setUpClass$|tearDown$|tearDownClass$|Meta$ diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index acdca76..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest==7.4.3 -pytest-cov==4.1.0 diff --git a/requirements/ecommerce-maple.master.txt b/requirements/ecommerce-palm.master.txt similarity index 75% rename from requirements/ecommerce-maple.master.txt rename to requirements/ecommerce-palm.master.txt index 8f8908f..e2c7644 100644 --- a/requirements/ecommerce-maple.master.txt +++ b/requirements/ecommerce-palm.master.txt @@ -6,57 +6,65 @@ # # make upgrade # +aiohttp==3.8.0 + # via + # -r requirements/base.txt + # inapppy amqp==2.6.1 # via # -r requirements/base.txt # kombu analytics-python==1.4.0 # via -r requirements/base.txt -asgiref==3.4.1 +asgiref==3.5.2 # via # -r requirements/base.txt # -r requirements/e2e.txt # django -asn1crypto==1.4.0 +asn1crypto==1.5.1 # via # -r requirements/base.txt # cybersource-rest-client-python -astroid==2.3.3 +astroid==2.9.3 # via pylint -attrs==21.2.0 +async-timeout==4.0.2 + # via + # -r requirements/base.txt + # redis +attrs==22.1.0 # via # -r requirements/base.txt # -r requirements/e2e.txt + # aiohttp # jsonschema # pytest # zeep -babel==2.9.1 +babel==2.10.3 # via # -r requirements/base.txt # django-oscar - # django-phonenumber-field backoff==1.10.0 # via # -r requirements/base.txt # analytics-python -bcrypt==3.2.0 +bcrypt==4.0.0 # via # -r requirements/base.txt # cybersource-rest-client-python # paramiko -beautifulsoup4==4.10.0 +beautifulsoup4==4.11.1 # via webtest billiard==3.6.4.0 # via # -r requirements/base.txt # celery -bleach==4.1.0 +bleach==5.0.1 # via -r requirements/base.txt bok-choy==1.1.1 # via -r requirements/test.in -boto3==1.19.10 +boto3==1.24.73 # via -r requirements/base.txt -botocore==1.22.10 +botocore==1.27.73 # via # -r requirements/base.txt # boto3 @@ -65,49 +73,59 @@ cached-property==1.5.2 # via # -r requirements/base.txt # zeep +cachetools==4.2.2 + # via + # -r requirements/base.txt + # google-auth celery==4.4.7 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/base.txt # edx-ecommerce-worker -certifi==2021.10.8 +certifi==2022.9.14 # via # -r requirements/base.txt # -r requirements/e2e.txt # cybersource-rest-client-python # requests -cffi==1.14.6 +cffi==1.15.1 # via # -r requirements/base.txt # -r requirements/e2e.txt - # bcrypt # cryptography # cybersource-rest-client-python # pynacl -chardet==4.0.0 +chardet==5.0.0 # via # -r requirements/base.txt + # -r requirements/e2e.txt + # aiohttp # cybersource-rest-client-python # diff-cover -charset-normalizer==2.0.6 +charset-normalizer==2.1.1 # via # -r requirements/base.txt # -r requirements/e2e.txt # requests -configparser==5.1.0 +click==8.1.3 + # via + # -r requirements/base.txt + # -r requirements/e2e.txt + # edx-django-utils +configparser==5.3.0 # via # -r requirements/base.txt # cybersource-rest-client-python coreapi==2.3.3 # via # -r requirements/base.txt - # django-rest-swagger - # openapi-codec + # drf-yasg coreschema==0.0.4 # via # -r requirements/base.txt # coreapi -coverage[toml]==6.1.1 + # drf-yasg +coverage[toml]==6.4.4 # via # -r requirements/base.txt # -r requirements/test.in @@ -117,9 +135,8 @@ crypto==1.4.1 # via # -r requirements/base.txt # cybersource-rest-client-python -cryptography==3.4.8 +cryptography==38.0.1 # via - # -c requirements/pins.txt # -r requirements/base.txt # -r requirements/e2e.txt # cybersource-rest-client-python @@ -131,28 +148,33 @@ cssselect==1.1.0 # via # -r requirements/base.txt # premailer -cssutils==2.3.0 +cssutils==2.6.0 # via # -r requirements/base.txt # premailer cybersource-rest-client-python==0.0.21 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/base.txt -datetime==4.3 +datetime==4.7 # via # -r requirements/base.txt # cybersource-rest-client-python -ddt==1.4.4 +ddt==1.6.0 # via -r requirements/test.in defusedxml==0.7.1 # via # -r requirements/base.txt # python3-openid # social-auth-core -diff-cover==6.4.2 +deprecated==1.2.13 + # via + # -r requirements/base.txt + # redis +diff-cover==6.5.1 # via -r requirements/test.in # via + # -c requirements/common_constraints.txt # -r requirements/base.txt # -r requirements/e2e.txt # django-appconf @@ -166,10 +188,12 @@ diff-cover==6.4.2 # django-model-utils # django-oscar # django-phonenumber-field + # django-solo # django-tables2 # django-treebeard # djangorestframework # drf-jwt + # drf-yasg # edx-auth-backends # edx-django-release-util # edx-django-sites-extensions @@ -177,23 +201,22 @@ diff-cover==6.4.2 # edx-drf-extensions # edx-i18n-tools # edx-rbac - # jsonfield2 - # mock-django + # jsonfield # rest-condition # xss-utils django-appconf==1.0.5 # via # -r requirements/base.txt # django-compressor -django-compressor==2.4.1 +django-compressor==4.1 # via # -r requirements/base.txt # django-libsass -django-config-models==2.2.0 +django-config-models==2.3.0 # via -r requirements/base.txt -django-cors-headers==3.10.0 +django-cors-headers==3.13.0 # via -r requirements/base.txt -django-crispy-forms==1.13.0 +django-crispy-forms==1.14.0 # via -r requirements/base.txt django-crum==0.7.9 # via @@ -201,13 +224,13 @@ django-crum==0.7.9 # -r requirements/e2e.txt # edx-django-utils # edx-rbac -django-extensions==3.1.3 +django-extensions==3.2.1 # via -r requirements/base.txt django-extra-views==0.13.0 # via # -r requirements/base.txt # django-oscar -django-filter==21.1 +django-filter==22.1 # via -r requirements/base.txt django-haystack==3.1.1 # via @@ -215,23 +238,23 @@ django-haystack==3.1.1 # django-oscar django-libsass==0.9 # via -r requirements/base.txt -django-model-utils==4.1.1 +django-model-utils==4.2.0 # via # -r requirements/base.txt # edx-rbac django-oscar==2.2 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/base.txt -django-phonenumber-field==3.0.1 +django-phonenumber-field==5.0.0 # via # -r requirements/base.txt # django-oscar -django-rest-swagger==2.2.0 - # via -r requirements/base.txt django-simple-history==3.0.0 - # via -r requirements/base.txt -django-solo==1.2.0 + # via + # -c requirements/common_constraints.txt + # -r requirements/base.txt +django-solo==2.0.0 # via -r requirements/base.txt django-tables2==2.4.1 # via @@ -243,70 +266,79 @@ django-treebeard==4.4 # via # -r requirements/base.txt # django-oscar -django-waffle==2.2.1 +django-waffle==3.0.0 # via # -r requirements/base.txt # -r requirements/e2e.txt # edx-django-utils # edx-drf-extensions -django-webtest==1.9.8 +django-webtest==1.9.10 # via -r requirements/test.in -django-widget-tweaks==1.4.8 +django-widget-tweaks==1.4.12 # via # -r requirements/base.txt # django-oscar -djangorestframework==3.12.4 +djangorestframework==3.13.1 # via # -r requirements/base.txt # django-config-models - # django-rest-swagger # djangorestframework-csv # djangorestframework-datatables # drf-extensions # drf-jwt + # drf-yasg # edx-drf-extensions # rest-condition djangorestframework-csv==2.1.1 # via -r requirements/base.txt -djangorestframework-datatables==0.6.0 +djangorestframework-datatables==0.7.0 # via -r requirements/base.txt drf-extensions==0.7.1 # via -r requirements/base.txt -drf-jwt==1.19.1 +drf-jwt==1.19.2 # via # -r requirements/base.txt # edx-drf-extensions +drf-yasg==1.20.0 + # via + # -c requirements/constraints.txt + # -r requirements/base.txt edx-auth-backends==3.4.0 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/base.txt -edx-django-release-util==1.1.0 +edx-braze-client==0.1.4 + # via + # -r requirements/base.txt + # edx-ecommerce-worker +edx-django-release-util==1.2.0 # via -r requirements/base.txt -edx-django-sites-extensions==3.1.0 +edx-django-sites-extensions==4.0.0 # via -r requirements/base.txt -edx-django-utils==4.4.0 +edx-django-utils==5.0.1 # via # -r requirements/base.txt # -r requirements/e2e.txt # django-config-models # edx-drf-extensions # edx-rest-api-client + # getsmarter-api-clients edx-drf-extensions==6.6.0 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/base.txt # edx-rbac -edx-ecommerce-worker==3.0.0 +edx-ecommerce-worker==3.3.2 # via -r requirements/base.txt -edx-i18n-tools==0.8.1 +edx-i18n-tools==0.9.1 # via -r requirements/test.in -edx-opaque-keys==2.2.2 +edx-opaque-keys==2.3.0 # via # -r requirements/base.txt # edx-drf-extensions -edx-rbac==1.5.1 +edx-rbac==1.7.0 # via -r requirements/base.txt -edx-rest-api-client==5.4.0 +edx-rest-api-client==5.5.0 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -326,11 +358,11 @@ factory-boy==2.12.0 # -r requirements/base.txt # -r requirements/test.in # django-oscar -faker==9.8.0 +faker==14.2.0 # via # -r requirements/base.txt # factory-boy -filelock==3.3.0 +filelock==3.8.0 # via # -r requirements/tox.txt # tox @@ -339,7 +371,7 @@ fixtures==3.0.0 # -r requirements/base.txt # cybersource-rest-client-python # testtools -freezegun==1.1.0 +freezegun==1.2.2 # via -r requirements/test.in funcsigs==1.0.2 # via @@ -349,23 +381,55 @@ future==0.18.2 # via # -r requirements/base.txt # pyjwkest +getsmarter-api-clients==0.4.0 + # via -r requirements/base.txt +google-api-core==1.30.0 + # via + # -r requirements/base.txt + # google-api-python-client +google-api-python-client==2.31.0 + # via -r requirements/base.in +google-auth-httplib2==0.1.0 + # via + # -r requirements/base.txt + # google-api-python-client +google-auth==1.32.1 + # via + # -r requirements/base.txt + # google-api-core + # google-api-python-client + # google-auth-httplib2 +googleapis-common-protos==1.53.0 + # via + # -r requirements/base.txt + # google-api-core +httplib2==0.20.2 + # via -r requirements/base.in httpretty==0.9.7 # via # -c requirements/pins.txt # -r requirements/test.in idna==2.7 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/base.txt # -r requirements/e2e.txt # cybersource-rest-client-python # requests -importlib-metadata==4.8.1 +importlib-metadata==4.12.0 # via # -r requirements/e2e.txt # pytest-randomly -inflect==5.3.0 - # via jinja2-pluralize +inapppy==2.5.2 + # via -r requirements/base.txt +importlib-resources==5.9.0 + # via + # -r requirements/base.txt + # jsonschema +inflection==0.5.1 + # via + # -r requirements/base.txt + # drf-yasg iniconfig==1.1.1 # via # -r requirements/e2e.txt @@ -374,11 +438,11 @@ ipaddress==1.0.23 # via # -r requirements/base.txt # cybersource-rest-client-python -isodate==0.6.0 +isodate==0.6.1 # via # -r requirements/base.txt # zeep -isort==4.3.21 +isort==5.10.1 # via # -r requirements/test.in # pylint @@ -386,26 +450,20 @@ itypes==1.2.0 # via # -r requirements/base.txt # coreapi -jinja2==3.0.2 +jinja2==3.1.2 # via # -r requirements/base.txt # coreschema # diff-cover - # jinja2-pluralize -jinja2-pluralize==0.3.0 - # via diff-cover -jmespath==0.10.0 +jmespath==1.0.1 # via # -r requirements/base.txt # boto3 # botocore -jsonfield2==3.0.3 - # via - # -c requirements/pins.txt - # -r requirements/base.txt -jsonschema==3.2.0 +jsonfield==3.1.0 + # via -r requirements/base.txt +jsonschema==4.16.0 # via - # -c requirements/pins.txt # -r requirements/base.txt # cybersource-rest-client-python kombu==4.6.11 @@ -414,7 +472,7 @@ kombu==4.6.11 # celery lazy==1.4 # via bok-choy -lazy-object-proxy==1.4.3 +lazy-object-proxy==1.7.1 # via astroid libsass==0.9.2 # via @@ -429,7 +487,7 @@ logger==1.4 # via # -r requirements/base.txt # cybersource-rest-client-python -lxml==4.6.4 +lxml==4.9.1 # via # -r requirements/base.txt # -r requirements/test.in @@ -437,22 +495,29 @@ lxml==4.6.4 # zeep markdown==2.6.9 # via -r requirements/base.txt -markupsafe==2.0.1 +markupsafe==2.1.1 # via # -r requirements/base.txt # jinja2 mccabe==0.6.1 - # via pylint -mock==4.0.3 # via - # -r requirements/test.in - # mock-django -mock-django==0.6.10 + # -c requirements/constraints.txt + # pylint +mock==4.0.3 # via -r requirements/test.in monotonic==1.6 # via # -r requirements/base.txt # analytics-python +more-itertools==8.8.0 + # via + # -r requirements/e2e.txt + # pytest +multidict==5.1.0 + # via + # -r requirements/base.txt + # aiohttp + # yarl mysqlclient==1.4.6 # via -r requirements/base.txt naked==0.1.31 @@ -462,7 +527,7 @@ naked==0.1.31 # cybersource-rest-client-python ndg-httpsclient==0.5.1 # via -r requirements/base.txt -newrelic==7.0.0.166 +newrelic==8.1.0.180 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -471,34 +536,41 @@ nose==1.3.7 # via # -r requirements/base.txt # cybersource-rest-client-python -oauthlib==3.1.1 +oauth2client==4.1.3 + # via + # -r requirements/base.txt + # inapppy +oauthlib==3.2.1 # via # -r requirements/base.txt + # getsmarter-api-clients # requests-oauthlib # social-auth-core openapi-codec==1.3.2 # via # -r requirements/base.txt # django-rest-swagger -packaging==21.0 +packaging==21.3 # via # -r requirements/base.txt # -r requirements/e2e.txt # -r requirements/tox.txt - # bleach + # drf-yasg + # google-api-core # pytest + # redis # tox -paramiko==2.8.0 +paramiko==2.11.0 # via # -r requirements/base.txt # cybersource-rest-client-python -path==16.2.0 +path==16.4.0 # via edx-i18n-tools -path.py==7.2 +path-py==7.2 # via -r requirements/base.txt paypalrestsdk==1.13.1 # via -r requirements/base.txt -pbr==5.7.0 +pbr==5.10.0 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -506,21 +578,26 @@ pbr==5.7.0 # fixtures # stevedore # testtools -phonenumbers==8.12.34 +phonenumbers==8.12.55 # via # -r requirements/base.txt # django-oscar -pillow==8.4.0 +pillow==9.2.0 # via # -r requirements/base.txt # django-oscar -platformdirs==2.4.0 +pkgutil-resolve-name==1.3.10 # via # -r requirements/base.txt + # jsonschema +platformdirs==2.5.2 + # via + # -r requirements/base.txt + # pylint # zeep pluggy==0.13.1 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/e2e.txt # -r requirements/tox.txt # diff-cover @@ -530,7 +607,12 @@ polib==1.1.1 # via edx-i18n-tools premailer==2.9.2 # via -r requirements/base.txt -psutil==5.8.0 +protobuf==3.17.3 + # via + # -r requirements/base.txt + # google-api-core + # googleapis-common-protos +psutil==5.9.2 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -539,39 +621,46 @@ purl==1.6 # via # -r requirements/base.txt # django-oscar -py==1.10.0 +py==1.11.0 # via # -r requirements/e2e.txt # -r requirements/tox.txt # pytest # tox +pyasn1-modules==0.2.8 + # via + # -r requirements/base.txt + # google-auth + # oauth2client pyasn1==0.4.8 # via # -r requirements/base.txt # cybersource-rest-client-python # ndg-httpsclient + # oauth2client + # pyasn1-modules # rsa # x509 -pycodestyle==2.8.0 +pycodestyle==2.9.1 # via -r requirements/test.in pycountry==17.1.8 # via -r requirements/base.txt -pycparser==2.20 +pycparser==2.21 # via # -r requirements/base.txt # -r requirements/e2e.txt # cffi # cybersource-rest-client-python -pycryptodome==3.11.0 +pycryptodome==3.15.0 # via # -r requirements/base.txt # cybersource-rest-client-python -pycryptodomex==3.11.0 +pycryptodomex==3.15.0 # via # -r requirements/base.txt # cybersource-rest-client-python # pyjwkest -pygments==2.10.0 +pygments==2.13.0 # via # -r requirements/base.txt # diff-cover @@ -588,36 +677,39 @@ pyjwt[crypto]==1.7.1 # edx-auth-backends # edx-rest-api-client # social-auth-core -pylint==2.4.4 +pylint==2.12.2 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/test.in -pymongo==3.12.0 +pymongo==3.12.3 # via # -r requirements/base.txt # edx-opaque-keys -pynacl==1.4.0 +pynacl==1.5.0 # via # -r requirements/base.txt + # -r requirements/e2e.txt # cybersource-rest-client-python + # edx-django-utils # paramiko -pyopenssl==21.0.0 +pyopenssl==22.0.0 # via # -r requirements/base.txt # cybersource-rest-client-python # ndg-httpsclient # paypalrestsdk -pyparsing==2.4.7 +pyparsing==3.0.9 # via # -r requirements/base.txt # -r requirements/e2e.txt # -r requirements/tox.txt + # httplib2 # packaging pypi==2.1 # via # -r requirements/base.txt # cybersource-rest-client-python -pyrsistent==0.18.0 +pyrsistent==0.18.1 # via # -r requirements/base.txt # jsonschema @@ -640,22 +732,21 @@ pytest-base-url==1.4.2 # pytest-selenium pytest-cov==3.0.0 # via -r requirements/test.in -pytest-django==4.4.0 - # via - # -r requirements/test.in +pytest-django==4.5.2 + # via -r requirements/test.in pytest-html==3.1.1 # via # -r requirements/e2e.txt # pytest-selenium -pytest-metadata==1.11.0 +pytest-metadata==2.0.2 # via # -r requirements/e2e.txt # pytest-html -pytest-randomly==3.10.1 +pytest-randomly==3.12.0 # via -r requirements/e2e.txt -pytest-selenium==2.0.1 +pytest-selenium==3.0.0 # via -r requirements/e2e.txt -pytest-timeout==2.0.0 +pytest-timeout==2.1.0 # via -r requirements/e2e.txt pytest-variables==1.9.0 # via @@ -669,7 +760,7 @@ python-dateutil==2.8.2 # edx-drf-extensions # faker # freezegun -python-dotenv==0.19.1 +python-dotenv==0.21.0 # via -r requirements/e2e.txt python-memcached==1.59 # via -r requirements/test.in @@ -691,7 +782,7 @@ python3-openid==3.2.0 # social-auth-core pytz==2016.10 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/base.txt # -r requirements/e2e.txt # babel @@ -699,23 +790,27 @@ pytz==2016.10 # cybersource-rest-client-python # datetime # django + # djangorestframework + # djangorestframework-datatables + # getsmarter-api-clients + # google-api-core # zeep -pyyaml==5.4.1 +pyyaml==6.0 # via # -r requirements/base.txt # cybersource-rest-client-python # edx-django-release-util # edx-i18n-tools # naked -rcssmin==1.0.6 +rcssmin==1.1.0 # via # -r requirements/base.txt # django-compressor -redis==3.5.3 +redis==4.3.4 # via # -r requirements/base.txt # edx-ecommerce-worker -requests==2.26.0 +requests==2.28.1 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -724,6 +819,8 @@ requests==2.26.0 # cybersource-rest-client-python # edx-drf-extensions # edx-rest-api-client + # google-api-core + # inapppy # naked # paypalrestsdk # pyjwkest @@ -741,41 +838,54 @@ requests-file==1.5.1 # via # -r requirements/base.txt # zeep -requests-oauthlib==1.3.0 +requests-oauthlib==1.3.1 # via # -r requirements/base.txt + # getsmarter-api-clients # social-auth-core requests-toolbelt==0.9.1 # via # -r requirements/base.txt # zeep -responses==0.14.0 +responses==0.21.0 # via -r requirements/test.in rest-condition==1.0.3 # via # -r requirements/base.txt # edx-drf-extensions -rjsmin==1.1.0 +rjsmin==1.2.0 # via # -r requirements/base.txt # django-compressor -rsa==4.7.2 +rsa==4.9 # via # -r requirements/base.txt # cybersource-rest-client-python -rules==3.0 + # google-auth + # inapppy + # oauth2client +ruamel-yaml==0.17.21 + # via + # -r requirements/base.txt + # drf-yasg +ruamel-yaml-clib==0.2.6 + # via + # -r requirements/base.txt + # ruamel-yaml +rules==3.3 # via -r requirements/base.txt -s3transfer==0.5.0 +s3transfer==0.6.0 # via # -r requirements/base.txt # boto3 selenium==3.141.0 # via + # -c requirements/constraints.txt # -r requirements/e2e.txt # -r requirements/test.in # bok-choy # pytest-selenium -semantic-version==2.8.5 +semantic-version==2.10.0 # via # -r requirements/base.txt # edx-drf-extensions @@ -784,22 +894,17 @@ shellescape==3.8.1 # -r requirements/base.txt # crypto # cybersource-rest-client-python -simplejson==3.17.5 - # via - # -r requirements/base.txt - # django-rest-swagger +simplejson==3.17.6 + # via -r requirements/base.txt six==1.16.0 # via # -r requirements/base.txt # -r requirements/e2e.txt # -r requirements/tox.txt # analytics-python - # astroid - # bcrypt # bleach # bok-choy # cybersource-rest-client-python - # django-compressor # django-extra-views # djangorestframework-csv # edx-auth-backends @@ -807,20 +912,21 @@ six==1.16.0 # edx-drf-extensions # edx-ecommerce-worker # edx-rbac - # fixtures - # httpretty + # google-api-core + # google-api-python-client + # google-auth + # google-auth-httplib2 # isodate - # jsonschema # libsass + # oauth2client + # paramiko # paypalrestsdk + # protobuf # purl # pyjwkest - # pynacl - # pyopenssl # python-dateutil # python-memcached # requests-file - # responses # social-auth-app-django # social-auth-core # tenacity @@ -832,59 +938,56 @@ slumber==0.7.1 # edx-rest-api-client social-auth-app-django==4.0.0 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/base.txt # edx-auth-backends social-auth-core==4.0.2 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/base.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.7.0 +sorl-thumbnail==12.9.0 # via -r requirements/base.txt -soupsieve==2.3 +soupsieve==2.3.2.post1 # via beautifulsoup4 sqlparse==0.4.2 # via # -r requirements/base.txt # -r requirements/e2e.txt # django -stevedore==3.4.0 +stevedore==4.0.0 # via # -r requirements/base.txt # -r requirements/e2e.txt # edx-django-utils # edx-opaque-keys -stripe==1.70.0 +stripe==4.1.0 # via -r requirements/base.txt tenacity==6.3.1 # via # -r requirements/e2e.txt # pytest-selenium -testfixtures==6.18.3 +testfixtures==7.0.0 # via -r requirements/test.in testtools==2.5.0 # via # -r requirements/base.txt # cybersource-rest-client-python - # fixtures # python-subunit -text-unidecode==1.3 - # via - # -r requirements/base.txt - # faker toml==0.10.2 # via # -r requirements/e2e.txt # -r requirements/tox.txt + # pylint # pytest + # pytest-cov # tox -tomli==1.2.1 +tomli==2.0.1 # via coverage tox==3.14.6 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/tox.txt # tox-battery tox-battery==0.6.1 @@ -893,21 +996,28 @@ traceback2==1.4.0 # via # -r requirements/base.txt # cybersource-rest-client-python + # testtools + # unittest2 typing==3.7.4.3 # via # -r requirements/base.txt # cybersource-rest-client-python +typing-extensions==4.3.0 + # via + # astroid + # pylint unicodecsv==0.14.1 # via # -r requirements/base.txt # djangorestframework-csv -uritemplate==4.0.0 +uritemplate==4.1.1 # via # -r requirements/base.txt # coreapi -urllib3==1.26.7 + # drf-yasg +urllib3==1.26.12 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/base.txt # -r requirements/e2e.txt # botocore @@ -922,10 +1032,10 @@ vine==1.3.0 # celery virtualenv==16.7.9 # via - # -c requirements/pins.txt + # -c requirements/constraints.txt # -r requirements/tox.txt # tox -waitress==2.0.0 +waitress==2.1.2 # via webtest webencodings==0.5.1 # via @@ -935,25 +1045,35 @@ webob==1.8.7 # via webtest webtest==3.0.0 # via django-webtest -wheel==0.37.0 +wheel==0.37.1 # via # -r requirements/base.txt # cybersource-rest-client-python -wrapt==1.11.2 - # via astroid +wrapt==1.13.3 + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # astroid + # deprecated x509==0.1 # via # -r requirements/base.txt # cybersource-rest-client-python -xss-utils==0.3.0 +xss-utils==0.4.0 # via -r requirements/base.txt +yarl==1.6.3 + # via + # -r requirements/base.txt + # aiohttp zeep==4.1.0 # via -r requirements/base.txt -zipp==3.6.0 +zipp==3.8.1 # via + # -r requirements/base.txt # -r requirements/e2e.txt # importlib-metadata -zope.interface==5.4.0 + # importlib-resources +zope-interface==5.4.0 # via # -r requirements/base.txt # cybersource-rest-client-python diff --git a/requirements/payfort-test.txt b/requirements/payfort-test.txt deleted file mode 100644 index e69de29..0000000 diff --git a/requirements/quality.txt b/requirements/quality.txt new file mode 100644 index 0000000..b459dda --- /dev/null +++ b/requirements/quality.txt @@ -0,0 +1,2 @@ +pylint-django==2.4.4 +edx-lint==5.2.0 diff --git a/requirements/transaltion.txt b/requirements/transaltion.txt new file mode 100644 index 0000000..32120bc --- /dev/null +++ b/requirements/transaltion.txt @@ -0,0 +1,11 @@ +asgiref==3.8.1 +Django==3.2.25 +edx-i18n-tools==1.6.0 +lxml==5.2.2 +lxml_html_clean==0.1.1 +path==16.14.0 +polib==1.2.0 +pytz==2024.1 +PyYAML==6.0.1 +sqlparse==0.5.0 +typing_extensions==4.11.0 diff --git a/ecommerce_payfort/tests/processors/__init__.py b/scripts/fake_assets/css/base/main.css similarity index 100% rename from ecommerce_payfort/tests/processors/__init__.py rename to scripts/fake_assets/css/base/main.css diff --git a/scripts/tox_install_ecommerce_run_pytest.sh b/scripts/tox_install_ecommerce_run_pytest.sh index c5c187f..3c72d9f 100644 --- a/scripts/tox_install_ecommerce_run_pytest.sh +++ b/scripts/tox_install_ecommerce_run_pytest.sh @@ -17,14 +17,23 @@ export PYTHONWARNINGS=ignore # Suppress warnings from `openedx/ecommerce` code pip install -e . # Install ecommerce_payfort into the virtualenv -if [ ! -d ".tox/ecommerce-maple.master" ]; then - git clone --single-branch --branch=open-release/maple.master --depth=1 https://github.com/openedx/ecommerce.git .tox/ecommerce-maple.master +if [ ! -d ".tox/ecommerce-palm.master" ]; then + git clone --single-branch --branch=open-release/palm.master --depth=1 https://github.com/openedx/ecommerce.git .tox/ecommerce-palm.master fi -rm -rf .tox/ecommerce-maple.master/ecommerce_payfort -cat settings/payfort.py > .tox/ecommerce-maple.master/ecommerce/settings/payfort.py +cat settings/test_settings.py > .tox/ecommerce-palm.master/ecommerce/settings/payfort.py -cd .tox/ecommerce-maple.master/ecommerce +#rm -rf .tox/ecommerce/assets +#cp -r scripts/fake_assets/ .tox/ecommerce/assets +export tests_root_dir=$(pwd) -"$@" # Arguments passed to this script +mkdir -p .tox/ecommerce +ln -s -f ../ecommerce-palm.master/ecommerce .tox/ecommerce/ecommerce +export PYTHONPATH="$PYTHONPATH:$tests_root_dir/.tox/ecommerce" +echo "PYTHONPATH=$PYTHONPATH" + +echo "***********************************************************----" +echo "* Running command from tox file:" "$@" +echo "***********************************************************----" +eval "$@" diff --git a/settings/ci.py b/settings/ci.py deleted file mode 100644 index 2ce4b37..0000000 --- a/settings/ci.py +++ /dev/null @@ -1,14 +0,0 @@ -from ecommerce.settings.payfort import * - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'test_ecommerce', - 'USER': 'root', - 'PASSWORD': 'password', - 'HOST': '127.0.0.1', - 'PORT': '3306', - 'ATOMIC_REQUESTS': True, - 'CONN_MAX_AGE': 60, - } -} diff --git a/settings/payfort.py b/settings/payfort.py deleted file mode 100644 index 4669f30..0000000 --- a/settings/payfort.py +++ /dev/null @@ -1,3 +0,0 @@ -from ecommerce.settings.test import * - -INSTALLED_APPS += ['ecommerce_payfort'] diff --git a/settings/test_settings.py b/settings/test_settings.py new file mode 100644 index 0000000..65c7d64 --- /dev/null +++ b/settings/test_settings.py @@ -0,0 +1,19 @@ +from ecommerce.settings.test import * + +INSTALLED_APPS += ["ecommerce_payfort"] + +payfort_settings = { + "access_code": "123123123", + "merchant_identifier": "mid123", + "request_sha_phrase": "secret@req", + "response_sha_phrase": "secret@res", + "sha_method": "SHA-256", + "ecommerce_url_root": "http://myecommerce.mydomain.com", +} +PAYMENT_PROCESSOR_CONFIG["edx"]["payfort"] = payfort_settings.copy() +PAYMENT_PROCESSOR_CONFIG["other"]["payfort"] = payfort_settings.copy() + +COMPRESS_ENABLED = False +COMPRESS_OFFLINE = False +COMPRESS_PRECOMPILERS = [] +ROOT_URLCONF = "ecommerce_payfort.tests.urls" diff --git a/setup.py b/setup.py index ea30069..7304e90 100644 --- a/setup.py +++ b/setup.py @@ -2,24 +2,34 @@ Setup file for the ecommerce-payfort Open edX ecommerce payment processor backend plugin. """ import os - +import re from pathlib import Path -from setuptools import setup +from setuptools import find_packages, setup README = open(Path(__file__).parent / 'README.rst').read() CHANGELOG = open(Path(__file__).parent / 'CHANGELOG.rst').read() -def package_data(pkg, roots): - """Generic function to find package_data. - - All of the files under each of the `roots` will be declared as package - data for package `pkg`. +def get_version(*file_paths): + """ + Extract the version string from the file. + @param file_paths: The path to the file containing the version string. + @type file_paths: multiple str """ + filename = os.path.join(os.path.dirname(__file__), *file_paths) + version_file = open(filename, encoding="utf8").read() + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError('Unable to find version string.') + + +def package_data(pkg, root_list): + """Generic function to find package_data for `pkg` under `root`.""" data = [] - for root in roots: + for root in root_list: for dirname, _, files in os.walk(os.path.join(pkg, root)): for fname in files: data.append(os.path.relpath(os.path.join(dirname, fname), pkg)) @@ -27,10 +37,12 @@ def package_data(pkg, roots): return {pkg: data} +VERSION = get_version('ecommerce_payfort', '__init__.py') + setup( name='ecommerce-payfort', description='PayFort ecommerce payment processor backend plugin', - version='0.1.0', + version=VERSION, author='ZeitLabs', author_email='info@zeitlabs.com', long_description=f'{README}\n\n{CHANGELOG}', @@ -42,7 +54,7 @@ def package_data(pkg, roots): classifiers=[ 'Development Status :: 3 - Alpha', 'Framework :: Django', - 'Framework :: Django :: 2.2', + 'Framework :: Django :: 3.2', 'Intended Audience :: Developers', 'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)', 'Natural Language :: English', @@ -53,12 +65,15 @@ def package_data(pkg, roots): 'Django~=3.2', ], package_data=package_data('ecommerce_payfort', ['locale']), - packages=[ - 'ecommerce_payfort', - ], + packages=find_packages( + include=[ + 'ecommerce_payfort', 'ecommerce_payfort.*', + ], + exclude=["*tests"], + ), entry_points={ 'ecommerce': [ - 'ecommerce_payfort = payfort.apps:PayFortConfig', + 'ecommerce_payfort = ecommerce_payfort.apps:PayFortConfig', ], }, ) diff --git a/tox.ini b/tox.ini index 4e13a65..d122e8b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = flake8,py38 +envlist = py38-{tests,quality} skipsdist = True [pytest] @@ -13,15 +13,19 @@ usedevelop=True allowlist_externals = bash setenv = DJANGO_SETTINGS_MODULE = ecommerce.settings.payfort + DB_NAME = test_db.sqlite3 +[testenv:py38-quality] deps = - -r{toxinidir}/requirements/ecommerce-maple.master.txt - -r{toxinidir}/requirements/payfort-test.txt + -r{toxinidir}/requirements/ecommerce-palm.master.txt + -r{toxinidir}/requirements/quality.txt commands = - # Clone the openedx/ecommerce and install the ecommerce-payfort package and run tests - bash ./scripts/tox_install_ecommerce_run_pytest.sh pytest {toxinidir}/ecommerce_payfort/{posargs} + bash ./scripts/tox_install_ecommerce_run_pytest.sh edx_lint write pylintrc && rm -f pylintrc_backup + bash ./scripts/tox_install_ecommerce_run_pytest.sh pylint ecommerce_payfort {posargs} -[testenv:flake8] -deps = flake8 -commands = flake8 ecommerce_payfort setup.py +[testenv:py38-tests] +deps = + -r{toxinidir}/requirements/ecommerce-palm.master.txt + +commands = bash ./scripts/tox_install_ecommerce_run_pytest.sh pytest {posargs} --cov-report term-missing --cov=./ecommerce_payfort --cov-fail-under=100 diff --git a/tutor_plugin/ecommerce-config.yml b/tutor_plugin/ecommerce-config.yml index 3a930fd..98a8421 100644 --- a/tutor_plugin/ecommerce-config.yml +++ b/tutor_plugin/ecommerce-config.yml @@ -1,9 +1,9 @@ cybersource: - merchant_id: SET-ME-PLEASE - flex_shared_secret_key_id: SET-ME-PLEASE - flex_shared_secret_key: SET-ME-PLEASE - soap_api_url: https://ics2wstest.ic3.com/commerce/1.x/transactionProcessor/CyberSourceTransaction_1.140.wsdl - transaction_key: SET-ME-PLEASE + merchant_id: SET-ME-PLEASE + flex_shared_secret_key_id: SET-ME-PLEASE + flex_shared_secret_key: SET-ME-PLEASE + soap_api_url: https://ics2wstest.ic3.com/commerce/1.x/transactionProcessor/CyberSourceTransaction_1.140.wsdl + transaction_key: SET-ME-PLEASE paypal: cancel_checkout_path: /checkout/cancel-checkout/ client_id: SET-ME-PLEASE