From bd2823cdcac952ea4ecd3b3412e1e5484f2b2418 Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Thu, 25 Jan 2024 15:08:30 +0000 Subject: [PATCH] feat: add command retry send to financial manager Create Django command to retry send Orders to financial manager. Fix when retry fullfillment, the BasketTransactionIntegration was rasing mysql error. Add tests. fix fccn/ecommerce-nau-extensions#4 fix fccn/ecommerce-nau-extensions#2 --- README.rst | 5 +- nau_extensions/admin.py | 4 +- nau_extensions/financial_manager.py | 62 ++++-- nau_extensions/forms.py | 3 +- .../retry_send_to_financial_manager.py | 79 +++++++ nau_extensions/models.py | 36 +++- nau_extensions/settings/test.py | 6 + nau_extensions/signals.py | 13 +- nau_extensions/strategy.py | 17 +- nau_extensions/tasks.py | 15 -- nau_extensions/tests/factories.py | 54 +++++ ...command_retry_send_to_financial_manager.py | 68 ++++++ .../tests/test_financial_manager.py | 196 ++++++++++++++++++ nau_extensions/tests/test_info.py | 13 -- nau_extensions/tests/test_models.py | 26 +++ nau_extensions/tests/test_vat.py | 30 +++ nau_extensions/tests/test_vatin.py | 39 ++++ nau_extensions/urls.py | 6 +- nau_extensions/vatin.py | 3 +- nau_extensions/views.py | 7 +- 20 files changed, 596 insertions(+), 86 deletions(-) create mode 100644 nau_extensions/management/commands/retry_send_to_financial_manager.py delete mode 100644 nau_extensions/tasks.py create mode 100644 nau_extensions/tests/factories.py create mode 100644 nau_extensions/tests/test_command_retry_send_to_financial_manager.py create mode 100644 nau_extensions/tests/test_financial_manager.py delete mode 100644 nau_extensions/tests/test_info.py create mode 100644 nau_extensions/tests/test_models.py create mode 100644 nau_extensions/tests/test_vat.py create mode 100644 nau_extensions/tests/test_vatin.py diff --git a/README.rst b/README.rst index 571e5c1..63c3a48 100644 --- a/README.rst +++ b/README.rst @@ -22,16 +22,13 @@ edit the `ecommerce/settings/private.py` file add change to:: ) LOGO_URL = "https://lms.nau.edu.pt/static/nau-basic/images/nau_azul.svg" - # Use custom tax strategy - NAU_EXTENSION_OSCAR_STRATEGY_CLASS = "ecommerce_plugin_paygate.strategy.DefaultStrategy" - # Configure tax as 23% used in Portugal NAU_EXTENSION_TAX_RATE = "0.298701299" # = 0.23/0.77 NAU_FINANCIAL_MANAGER = { "edx": { "url": "http://financial-manager.local.nau.fccn.pt:8000/api/billing/transaction-complete/", - "token": "abcdABCD1234", + "token": "Bearer abcdABCD1234", } } diff --git a/nau_extensions/admin.py b/nau_extensions/admin.py index f76bc24..786faff 100644 --- a/nau_extensions/admin.py +++ b/nau_extensions/admin.py @@ -2,8 +2,8 @@ from django.contrib import admin from django.utils.html import format_html - -from .models import BasketBillingInformation, BasketTransactionIntegration +from nau_extensions.models import (BasketBillingInformation, + BasketTransactionIntegration) admin.site.register(BasketBillingInformation) diff --git a/nau_extensions/financial_manager.py b/nau_extensions/financial_manager.py index 3c1ac1e..acc2989 100644 --- a/nau_extensions/financial_manager.py +++ b/nau_extensions/financial_manager.py @@ -6,12 +6,12 @@ import requests from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from nau_extensions.models import (BasketBillingInformation, + BasketTransactionIntegration) +from nau_extensions.utils import get_order from opaque_keys.edx.keys import CourseKey from oscar.core.loading import get_class, get_model -from .models import BasketTransactionIntegration -from .utils import get_order - logger = logging.getLogger(__name__) Selector = get_class("partner.strategy", "Selector") Order = get_model("order", "Order") @@ -50,12 +50,19 @@ def sync_request_data(bti: BasketTransactionIntegration) -> dict: # initialize strategy basket = bti.basket basket.strategy = Selector().strategy(user=basket.owner) - bbi = basket.basket_billing_information + bbi = BasketBillingInformation.get_by_basket(basket) + order = get_order(basket) + address_line_1 = bbi.line1 address_line_2 = bbi.line2 + ( - ("," + bbi.line3) if bbi.line3 and len(bbi.line3) > 0 else "" + ("," + bbi.line3) if bbi.line3 and len(bbi.line3) > 0 else '' ) - order = get_order(basket) + city = bbi.line4 if bbi else '' + postal_code = bbi.postcode if bbi else '' + state = bbi.state if bbi else '' + country_code = bbi.country.iso_3166_1_a2 if bbi else '' + vat_identification_number = bbi.vatin if bbi else '' + vat_identification_country = bbi.country.iso_3166_1_a2 if bbi else '' # generate a dict with all request data request_data = { @@ -63,14 +70,14 @@ def sync_request_data(bti: BasketTransactionIntegration) -> dict: "transaction_type": "credit", "client_name": basket.owner.full_name, "email": basket.owner.email, - "address_line_1": bbi.line1, + "address_line_1": address_line_1, "address_line_2": address_line_2, - "city": bbi.line4, - "postal_code": bbi.postcode, - "state": bbi.state, - "country_code": bbi.country.iso_3166_1_a2, - "vat_identification_number": bbi.vatin, - "vat_identification_country": bbi.country.iso_3166_1_a2, + "city": city, + "postal_code": postal_code, + "state": state, + "country_code": country_code, + "vat_identification_number": vat_identification_number, + "vat_identification_country": vat_identification_country, "total_amount_exclude_vat": basket.total_excl_tax, "total_amount_include_vat": basket.total_incl_tax, "currency": basket.currency, @@ -102,17 +109,24 @@ def _convert_order_lines(order): for line in order.lines.all(): # line.discount_incl_tax # line.discount_excl_tax - course_run_key = CourseKey.from_string(line.product.course.id) + course = line.product.course + course_id = course.id if course else line.product.title + course_key = CourseKey.from_string(course.id) if course else None + organization_code = course_key.org if course else None + product_code = course_key.course if course else None + amount_exclude_vat = line.quantity * line.unit_price_excl_tax + amount_include_vat = line.quantity * line.unit_price_incl_tax + vat_tax = amount_include_vat - amount_exclude_vat result.append( { "description": line.title, "quantity": line.quantity, - "vat_tax": 1 - (line.unit_price_excl_tax - line.unit_price_incl_tax), - "amount_exclude_vat": line.quantity * line.unit_price_excl_tax, - "amount_include_vat": line.quantity * line.unit_price_incl_tax, - "organization_code": course_run_key.org, - "product_code": course_run_key.course, - "product_id": line.product.course.id, + "vat_tax": vat_tax, + "amount_exclude_vat": amount_exclude_vat, + "amount_include_vat": amount_include_vat, + "organization_code": organization_code, + "product_code": product_code, + "product_id": course_id, } ) return result @@ -138,13 +152,17 @@ def send_to_financial_manager_if_enabled( ) # update state - if basket_transaction_integration.status_code == 200: + if response.status_code == 200: state = BasketTransactionIntegration.SENT_WITH_SUCCESS else: state = BasketTransactionIntegration.SENT_WITH_ERROR basket_transaction_integration.state = state # save the response output - basket_transaction_integration.response = response.content + try: + response_json = response.json() + except Exception as e: # pylint: disable=broad-except + logger.exception("Error can't parse send to financial manager response as json [%s]", e) + basket_transaction_integration.response = response_json basket_transaction_integration.save() diff --git a/nau_extensions/forms.py b/nau_extensions/forms.py index 5e5dbf8..379fa3c 100644 --- a/nau_extensions/forms.py +++ b/nau_extensions/forms.py @@ -1,9 +1,8 @@ from django import forms +from nau_extensions.models import BasketBillingInformation from oscar.apps.address.forms import AbstractAddressForm from oscar.core.loading import get_model -from .models import BasketBillingInformation - Basket = get_model("basket", "Basket") diff --git a/nau_extensions/management/commands/retry_send_to_financial_manager.py b/nau_extensions/management/commands/retry_send_to_financial_manager.py new file mode 100644 index 0000000..34e7dbe --- /dev/null +++ b/nau_extensions/management/commands/retry_send_to_financial_manager.py @@ -0,0 +1,79 @@ +""" +Script to synchronize courses to Richie marketing site +""" + +import logging +from datetime import datetime, timedelta + +from django.core.management.base import BaseCommand +from nau_extensions.financial_manager import \ + send_to_financial_manager_if_enabled +from nau_extensions.models import BasketTransactionIntegration +from oscar.core.loading import get_model + +Basket = get_model("basket", "Basket") + + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Command that retries to send the BasketTransactionIntegration objects to + the Financial Manager system. + By default, will retry the BasketTransactionIntegration objects where its state is sent with + error and also send the pending to be sent that have been created more than 5 minutes ago. + """ + + help = ( + "Retry send the BasketTransactionIntegration objects to the " + "Financial Manager system that have been sent with error or that " + "haven't being sent on the last 5 minutes" + ) + + def add_arguments(self, parser): + parser.add_argument( + "--basket_id", + type=str, + default=None, + help="Basket id to synchronize, otherwise all pending baskets will be sent", + ) + parser.add_argument( + "--delta_to_be_sent_in_seconds", + type=int, + default=300, + help="Delta in seconds to retry the To be sent state", + ) + + def handle(self, *args, **kwargs): + """ + Synchronize courses to the Richie marketing site, print to console its sync progress. + """ + btis: list = None + + basket_id = kwargs["basket_id"] + if basket_id: + basket = Basket.objects.filter(id=basket_id) + if not basket: + raise ValueError(f"No basket found for basket_id={basket_id}") + bti = BasketTransactionIntegration.get_by_basket(basket) + if not bti: + raise ValueError( + f"No basket transaction integration found for basket_id={basket_id}" + ) + btis = [bti] + else: + btis = BasketTransactionIntegration.objects.filter( + state__in=[ + BasketTransactionIntegration.SENT_WITH_ERROR, + BasketTransactionIntegration.TO_BE_SENT, + ] + ) + + delta_to_be_sent_in_seconds = kwargs["delta_to_be_sent_in_seconds"] + for bti in btis: + if bti.created <= datetime.now(bti.created.tzinfo) - timedelta( + seconds=delta_to_be_sent_in_seconds + ): + log.info("Sending to financial manager basket_id=%d", bti.basket.id) + send_to_financial_manager_if_enabled(bti) diff --git a/nau_extensions/models.py b/nau_extensions/models.py index 97f0b9e..7cf68ff 100644 --- a/nau_extensions/models.py +++ b/nau_extensions/models.py @@ -2,12 +2,11 @@ from django.forms import ValidationError from django.utils.translation import ugettext_lazy as _ from jsonfield import JSONField +from nau_extensions.utils import get_order +from nau_extensions.vatin import check_country_vatin from oscar.apps.address.abstract_models import AbstractAddress from oscar.core.loading import get_model -from .utils import get_order -from .vatin import check_country_vatin - Basket = get_model("basket", "Basket") Country = get_model("address", "Country") @@ -79,6 +78,16 @@ def active_address_fields_except_country(self): fields = self.base_fields.remove("country") return self.get_address_field_values(fields) + @classmethod + def get_by_basket(cls, basket): + """ + Get the `BasketBillingInformation` instance from a `basket` instance. + This is required because the `basket` class doesn't know this one. + And the relation basket.basket_billing_information isn't recognized + by Django. + """ + return BasketBillingInformation.objects.filter(basket=basket).first() + class BasketTransactionIntegration(models.Model): """ @@ -120,10 +129,10 @@ class BasketTransactionIntegration(models.Model): class Meta: get_latest_by = "created" - @staticmethod - def create(basket): + @classmethod + def create(cls, basket): """ - Create a new basket transaction integration for a basket. + Create a new basket basket transaction integration or reuse an existing one for a basket. """ order = get_order(basket) if not order: @@ -131,4 +140,17 @@ def create(basket): f"The creation of BasketTransactionIntegration requires a basket with an order" f", basket '{basket}'" ) - return BasketTransactionIntegration(basket=basket) + bti = cls.get_by_basket(basket) + if not bti: + bti = BasketTransactionIntegration(basket=basket) + return bti + + @classmethod + def get_by_basket(cls, basket): + """ + Get the `BasketTransactionIntegration` instance from a `basket` instance. + This is required because the `basket` class doesn't know this one. + And the relation basket.basket_transaction_integration isn't recognized + by Django. + """ + return BasketTransactionIntegration.objects.filter(basket=basket).first() diff --git a/nau_extensions/settings/test.py b/nau_extensions/settings/test.py index 37b0c6a..d224fe4 100644 --- a/nau_extensions/settings/test.py +++ b/nau_extensions/settings/test.py @@ -1,4 +1,10 @@ """ Test settings for the ecommerce nau extensions """ + from ecommerce.settings.test import * + +INSTALLED_APPS += ("nau_extensions",) + +# This setting needs to be specified on this level. +NAU_EXTENSION_OSCAR_RATE_TAX_STRATEGY_CLASS = "nau_extensions.strategy.SettingFixedRateTax" diff --git a/nau_extensions/signals.py b/nau_extensions/signals.py index 10cd135..4da757f 100644 --- a/nau_extensions/signals.py +++ b/nau_extensions/signals.py @@ -1,9 +1,10 @@ +from django.db import transaction from django.dispatch import receiver +from nau_extensions.financial_manager import \ + send_to_financial_manager_if_enabled +from nau_extensions.models import BasketTransactionIntegration from oscar.core.loading import get_class -from .models import BasketTransactionIntegration -from .tasks import send_basket_transaction_integration_to_financial_manager - post_checkout = get_class("checkout.signals", "post_checkout") @@ -19,5 +20,7 @@ def create_and_send_basket_transaction_integration_to_financial_manager( Create a Basket Transaction Integration object after a checkout of an Order; then send that information to the nau-financial-manager service. """ - BasketTransactionIntegration.create(order.basket).save() - send_basket_transaction_integration_to_financial_manager.delay(order.basket) + with transaction.atomic(): + bti = BasketTransactionIntegration.create(order.basket).save() + + send_to_financial_manager_if_enabled(bti) diff --git a/nau_extensions/strategy.py b/nau_extensions/strategy.py index 8b79f36..e114bd6 100644 --- a/nau_extensions/strategy.py +++ b/nau_extensions/strategy.py @@ -2,20 +2,21 @@ Django Oscar strategy for fixed rate tax. Use a fixed rate tax read from a setting. """ + from decimal import Decimal as D from django.conf import settings from oscar.apps.partner import strategy -from ecommerce.extensions.partner.strategy import \ - CourseSeatAvailabilityPolicyMixin +class SettingFixedRateTax(strategy.FixedRateTax): + """ + A custom rate tax that loads a fixed value from a setting. + This means that everything we sell has a fixed VAT value. + """ -class DefaultStrategy( - strategy.UseFirstStockRecord, - CourseSeatAvailabilityPolicyMixin, - strategy.FixedRateTax, - strategy.Structured, -): def get_rate(self, product, stockrecord): + """ + The rate VAT that all products have. + """ return D(settings.NAU_EXTENSION_TAX_RATE) diff --git a/nau_extensions/tasks.py b/nau_extensions/tasks.py deleted file mode 100644 index 373a38c..0000000 --- a/nau_extensions/tasks.py +++ /dev/null @@ -1,15 +0,0 @@ -from celery import shared_task - -from .financial_manager import send_to_financial_manager_if_enabled - - -@shared_task(bind=True, ignore_result=True) -def send_basket_transaction_integration_to_financial_manager(self, basket): # pylint: disable=unused-argument - """ - Send Basket Transaction Integration to the nau-financial-manager service. - - Args: - self: Ignore - basket (Basket): the basket to send - """ - send_to_financial_manager_if_enabled(basket.basket_transaction_integration) diff --git a/nau_extensions/tests/factories.py b/nau_extensions/tests/factories.py new file mode 100644 index 0000000..61bff00 --- /dev/null +++ b/nau_extensions/tests/factories.py @@ -0,0 +1,54 @@ +from decimal import Decimal as D + +from oscar.test.factories import (Basket, create_product, create_stockrecord, + get_model) + +from ecommerce.extensions.partner.strategy import DefaultStrategy +from ecommerce.tests.factories import SiteConfigurationFactory, UserFactory + +ProductClass = get_model("catalogue", "ProductClass") + + +def create_basket( + owner=None, site=None, empty=False, price="10.00", product_class=None +): # pylint:disable=function-redefined + """ + Create a basket for testing inside of the NAU extensions project. + """ + if site is None: + site = SiteConfigurationFactory().site + if owner is None: + owner = UserFactory() + basket = Basket.objects.create(site=site, owner=owner) + basket.strategy = DefaultStrategy() + if not empty: + if product_class: + product_class_instance = ProductClass.objects.get(name=product_class) + product = create_product(product_class=product_class_instance) + else: + product = create_product() + create_stockrecord(product, num_in_stock=2, price_excl_tax=D(price)) + basket.add_product(product) + return basket + + +class MockResponse: + """ + A mocked requests response. + """ + + def __init__(self, json_data=None, status_code=200): + self.json_data = json_data + self.status_code = status_code + + def json(self): + """ + The Json output that will be mocked + """ + return self.json_data + + def content(self): + """ + The Json data + """ + return self.json_data diff --git a/nau_extensions/tests/test_command_retry_send_to_financial_manager.py b/nau_extensions/tests/test_command_retry_send_to_financial_manager.py new file mode 100644 index 0000000..5c45fcf --- /dev/null +++ b/nau_extensions/tests/test_command_retry_send_to_financial_manager.py @@ -0,0 +1,68 @@ +from io import StringIO + +import mock +from django.core.management import call_command +from django.test import TestCase +from nau_extensions.models import BasketTransactionIntegration + +from ecommerce.extensions.test.factories import create_order +from ecommerce.tests.testcases import TestCase + + +@mock.patch( + "nau_extensions.management.commands.retry_send_to_financial_manager.send_to_financial_manager_if_enabled" +) +class CommandsNAUExtensionsTests(TestCase): + """ + This class aims to test the VAT customization required by NAU. + """ + + def _create_basket_transaction_integration(self, state=None): + order = create_order() + basket = order.basket + bti = BasketTransactionIntegration.create(basket) + if state: + bti.state = state + bti.save() + return bti + + def test_retry_send_to_financial_manager_once(self, send_mock): + """ + Test that retry sending a single BasketTransactionIntegration object to financial manager. + """ + self._create_basket_transaction_integration( + state=BasketTransactionIntegration.SENT_WITH_SUCCESS + ) + bti_pending = self._create_basket_transaction_integration() + + call_command("retry_send_to_financial_manager", delta_to_be_sent_in_seconds=0) + send_mock.assert_called_once_with(bti_pending) + + def test_retry_send_to_financial_manager_one_each_state(self, send_mock): + """ + Test that retry sending with one BasketTransactionIntegration object per state + to financial manager. The sent with success state shouldn't be sent. + """ + bti_sent = self._create_basket_transaction_integration() + bti_sent.state = BasketTransactionIntegration.SENT_WITH_SUCCESS + bti_sent.save() + + bti_pending = self._create_basket_transaction_integration() + bti_pending.state = BasketTransactionIntegration.TO_BE_SENT + bti_pending.save() + + bti_error = self._create_basket_transaction_integration() + bti_error.state = BasketTransactionIntegration.SENT_WITH_ERROR + bti_error.save() + + call_command("retry_send_to_financial_manager", delta_to_be_sent_in_seconds=0) + self.assertEqual(send_mock.call_count, 2) + + def test_retry_send_to_financial_manager_multiple(self, send_mock): + """ + Test that retry sending multiple BasketTransactionIntegration object to financial manager. + """ + for _ in range(10): + self._create_basket_transaction_integration() + call_command("retry_send_to_financial_manager", delta_to_be_sent_in_seconds=0) + self.assertEqual(send_mock.call_count, 10) diff --git a/nau_extensions/tests/test_financial_manager.py b/nau_extensions/tests/test_financial_manager.py new file mode 100644 index 0000000..5da10ab --- /dev/null +++ b/nau_extensions/tests/test_financial_manager.py @@ -0,0 +1,196 @@ +from decimal import Decimal + +import mock +import requests +from django.test import override_settings +from nau_extensions.financial_manager import ( + send_to_financial_manager_if_enabled, sync_request_data) +from nau_extensions.models import (BasketBillingInformation, + BasketTransactionIntegration) +from nau_extensions.tests.factories import MockResponse +from oscar.test.factories import CountryFactory + +from ecommerce.courses.tests.factories import CourseFactory +from ecommerce.extensions.test.factories import create_basket, create_order +from ecommerce.tests.factories import (PartnerFactory, + SiteConfigurationFactory, SiteFactory, + UserFactory) +from ecommerce.tests.testcases import TestCase + + +class FinancialManagerNAUExtensionsTests(TestCase): + """ + This class aims to test the specifics of the nau extensions project related to the + integration of financial manager. + """ + + # To view the full difference of the asserted dictionaries + maxDiff = None + + # OSCAR_DEFAULT_CURRENCY + # NAU_EXTENSION_TAX_RATE = "0.298701299" # = 0.23/0.77 + # also test without bbi + @override_settings(OSCAR_DEFAULT_CURRENCY="EUR") + def test_financial_manager_sync_data_basic(self): + """ + Test the synchronization of data between the models and the `BasketTransactionIntegration` model. + """ + # order_number = 'OPENEDX-100001' + partner = PartnerFactory(short_code="edX") + course = CourseFactory( + id="course-v1:edX+DemoX+Demo_Course", + name="edX Demonstration Course", + partner=partner, + ) + honor_product = course.create_or_update_seat("honor", False, 0) + verified_product = course.create_or_update_seat("verified", True, 10) + + owner = UserFactory(email="ecommerce@example.com") + + # create an empty basket so we know what it's inside + basket = create_basket(owner=owner, empty=True) + basket.add_product(verified_product) + basket.add_product(honor_product) + + # creating an order will mark the card submitted + create_order(basket=basket) + + bti = BasketTransactionIntegration.create(basket) + + basket.save() + bti.save() + + country = CountryFactory(iso_3166_1_a2="PT", printable_name="Portugal") + country.save() + + bbi = BasketBillingInformation() + bbi.line1 = "Av. do Brasil n.º 101" + bbi.line2 = "" + bbi.line3 = "" + bbi.line4 = "Lisboa" + bbi.state = "Lisboa" + bbi.postcode = "1700-066" + bbi.country = country + bbi.basket = basket + bbi.vatin = "123456789" + bbi.save() + + sync_request_data(bti) + + self.assertDictEqual( + bti.request, + { + "transaction_id": basket.order_number, + "transaction_type": "credit", + "client_name": owner.full_name, + "email": "ecommerce@example.com", + "address_line_1": "Av. do Brasil n.º 101", + "address_line_2": "", + "city": "Lisboa", + "postal_code": "1700-066", + "state": "Lisboa", + "country_code": "PT", + "vat_identification_number": "123456789", + "vat_identification_country": "PT", + "total_amount_exclude_vat": Decimal("10.00"), + "total_amount_include_vat": Decimal("10.00"), + "currency": "EUR", # requires that the 'OSCAR_DEFAULT_CURRENCY' setting is changed + "payment_type": None, + "items": [ + # verified + { + "amount_exclude_vat": Decimal("10.00"), + "amount_include_vat": Decimal("10.00"), + "description": "Seat in edX Demonstration Course with verified certificate", + "organization_code": "edX", + "product_code": "DemoX", + "product_id": "course-v1:edX+DemoX+Demo_Course", + "quantity": 1, + "vat_tax": Decimal("0.00"), + }, + # honor + { + "amount_exclude_vat": Decimal("0.00"), + "amount_include_vat": Decimal("0.00"), + "description": "Seat in edX Demonstration Course with honor certificate", + "organization_code": "edX", + "product_code": "DemoX", + "product_id": "course-v1:edX+DemoX+Demo_Course", + "quantity": 1, + "vat_tax": Decimal("0.00"), + }, + ], + }, + ) + + @override_settings( + NAU_FINANCIAL_MANAGER={ + "edx": { + "url": "https://finacial-manager.example.com/api/billing/transaction-complete/", + "token": "a-very-long-token", + }, + }, + ) + def test_send_to_financial_manager(self): + """ + Test that send to financial manager system. + """ + partner = PartnerFactory(short_code="edX") + + site_configuration = SiteConfigurationFactory(partner=partner) + site_configuration.site = SiteFactory(name="openedx") + site = site_configuration.site + + course = CourseFactory( + id="course-v1:edX+DemoX+Demo_Course", + name="edX Demonstration Course", + partner=partner, + ) + honor_product = course.create_or_update_seat("honor", False, 0) + verified_product = course.create_or_update_seat("verified", True, 10) + + owner = UserFactory(email="ecommerce@example.com") + + # create an empty basket so we know what it's inside + basket = create_basket(owner=owner, empty=True, site=site) + basket.add_product(verified_product) + basket.add_product(honor_product) + basket.save() + + # create billing information + country = CountryFactory(iso_3166_1_a2="PT", printable_name="Portugal") + country.save() + bbi = BasketBillingInformation() + bbi.line1 = "Av. do Brasil n.º 101" + bbi.line2 = "" + bbi.line3 = "" + bbi.line4 = "Lisboa" + bbi.state = "Lisboa" + bbi.postcode = "1700-066" + bbi.country = country + bbi.basket = basket + bbi.vatin = "123456789" + bbi.save() + + # creating an order will mark the card submitted + create_order(basket=basket) + + bti = BasketTransactionIntegration.create(basket) + bti.save() + + mock_response_json_data = { + "some": "stuff", + } + + with mock.patch.object( + requests, + "post", + return_value=MockResponse( + json_data=mock_response_json_data, + status_code=200, + ), + ): + send_to_financial_manager_if_enabled(bti) + + self.assertEqual(bti.state, BasketTransactionIntegration.SENT_WITH_SUCCESS) + self.assertEqual(mock_response_json_data, bti.response) diff --git a/nau_extensions/tests/test_info.py b/nau_extensions/tests/test_info.py deleted file mode 100644 index 92d62f5..0000000 --- a/nau_extensions/tests/test_info.py +++ /dev/null @@ -1,13 +0,0 @@ -from ecommerce.tests.testcases import TestCase - - -class InfoTests(TestCase): - """ - AAA - """ - - def test_cancel_response_view_default(self): - """ - BBB - """ - pass diff --git a/nau_extensions/tests/test_models.py b/nau_extensions/tests/test_models.py new file mode 100644 index 0000000..a20dae2 --- /dev/null +++ b/nau_extensions/tests/test_models.py @@ -0,0 +1,26 @@ +from nau_extensions.models import BasketTransactionIntegration + +from ecommerce.extensions.test.factories import create_order +from ecommerce.tests.factories import UserFactory +from ecommerce.tests.testcases import TestCase + + +class ModelsNAUExtensionsTests(TestCase): + """ + This class aims to test the specifics of the nau extensions project related to django models. + """ + + def test_basket_integration_integration_create_second_same_basket(self): + """ + Test the creation of a second `BasketTransactionIntegration` for same basket/order. + """ + order = create_order(user=UserFactory()) + bti = BasketTransactionIntegration.create(order.basket) + bti.save() + + # the 2nd call on create should not fail + bti2 = BasketTransactionIntegration.create(order.basket) + bti2.save() + + self.assertNotEqual(bti.id, None) + self.assertEqual(bti.id, bti2.id) diff --git a/nau_extensions/tests/test_vat.py b/nau_extensions/tests/test_vat.py new file mode 100644 index 0000000..a5e006d --- /dev/null +++ b/nau_extensions/tests/test_vat.py @@ -0,0 +1,30 @@ +from decimal import Decimal + +from django.test import override_settings +from nau_extensions.tests.factories import create_basket + +from ecommerce.extensions.partner.strategy import DefaultStrategy +from ecommerce.tests.testcases import TestCase + + +@override_settings( + NAU_EXTENSION_TAX_RATE="0.298701299", # = 0.23/0.77 +) +class VATNAUExtensionsTests(TestCase): + """ + This class aims to test the VAT customization required by NAU. + """ + + def test_vat_tax(self): + """ + Test that the VAT tax extension is applied on Django Oscar strategy. + """ + self.assertEqual(DefaultStrategy().get_rate(None, None), Decimal("0.298701299")) + + def test_basket_with_vat_tax(self): + """ + Test that VAT tax value is applied on a basket. + """ + basket = create_basket(price="15.40") + self.assertEqual(basket.total_excl_tax, round(Decimal(15.40), 2)) + self.assertEqual(basket.total_incl_tax, round(Decimal(20.00), 2)) diff --git a/nau_extensions/tests/test_vatin.py b/nau_extensions/tests/test_vatin.py new file mode 100644 index 0000000..3e3b101 --- /dev/null +++ b/nau_extensions/tests/test_vatin.py @@ -0,0 +1,39 @@ +from nau_extensions.vatin import check_country_vatin + +from ecommerce.tests.testcases import TestCase + + +class VATINNAUExtensionsTests(TestCase): + """ + This class aims to test the VAT customization required by NAU. + """ + + def test_vatin_pt_fake(self): + """ + Test the VATIN validator for a fake VAT identification number in Portugal. + """ + self.assertEqual(True, check_country_vatin("PT", "123456789")) + + def test_vatin_pt_fct(self): + """ + Test the VATIN validator for a real VAT identification number in Portugal. + """ + self.assertEqual(True, check_country_vatin("PT", "600021505")) + + def test_vatin_fr(self): + """ + Test the VATIN validator for a fake VAT identification number in France. + """ + self.assertEqual(True, check_country_vatin("FR", "12345678901")) + + def test_vatin_es(self): + """ + Test the VATIN validator for a fake VAT identification number in Spain. + """ + self.assertEqual(True, check_country_vatin("ES", "B34562534")) + + def test_vatin_de(self): + """ + Test the VATIN validator for a fake VAT identification number in Germany. + """ + self.assertEqual(True, check_country_vatin("DE", "123456789")) diff --git a/nau_extensions/urls.py b/nau_extensions/urls.py index 4859eaa..04ce65e 100644 --- a/nau_extensions/urls.py +++ b/nau_extensions/urls.py @@ -1,7 +1,7 @@ from django.conf.urls import url - -from .views import (BasketBillingInformationAddressCreateUpdateView, - BasketBillingInformationVATINCreateUpdateView) +from nau_extensions.views import ( + BasketBillingInformationAddressCreateUpdateView, + BasketBillingInformationVATINCreateUpdateView) app_name = "ecommerce_nau_extensions" diff --git a/nau_extensions/vatin.py b/nau_extensions/vatin.py index 12f6a24..356f83b 100644 --- a/nau_extensions/vatin.py +++ b/nau_extensions/vatin.py @@ -43,7 +43,8 @@ def check_country_vatin(country_iso_3166_1_a2: str, vatin: str) -> bool: is_regex_valid = VATIN_REGEX.match((country_iso_3166_1_a2 + vatin)) is not None if country_iso_3166_1_a2 == 'PT': - from .nif import controlNIF # pylint: disable=import-outside-toplevel + from nau_extensions.nif import \ + controlNIF # pylint: disable=import-outside-toplevel return controlNIF(vatin) return is_regex_valid diff --git a/nau_extensions/views.py b/nau_extensions/views.py index c8ced68..15cf40e 100644 --- a/nau_extensions/views.py +++ b/nau_extensions/views.py @@ -9,12 +9,11 @@ from django.http import HttpResponse, HttpResponseForbidden from django.utils.translation import ugettext_lazy as _ from django.views import generic +from nau_extensions.forms import (BasketBillingInformationAddressForm, + BasketBillingInformationVATINForm) +from nau_extensions.models import BasketBillingInformation from oscar.core.loading import get_class, get_model -from .forms import (BasketBillingInformationAddressForm, - BasketBillingInformationVATINForm) -from .models import BasketBillingInformation - logger = logging.getLogger(__name__) UserAddress = get_model("address", "UserAddress")