diff --git a/cart/templates/cart.html b/cart/templates/cart.html index 6247d84c..cae3dd4a 100644 --- a/cart/templates/cart.html +++ b/cart/templates/cart.html @@ -103,7 +103,6 @@ function clear_cart(event) { event.preventDefault(); - var slug = "{{ basket.integrated_system.slug }}"; var csrfmiddlewaretoken = encodeURIComponent(document.querySelector("input[name='csrfmiddlewaretoken']").value); axios.delete(`/api/v0/payments/baskets/clear/`, { headers: { "X-CSRFToken": csrfmiddlewaretoken } }) diff --git a/cart/views.py b/cart/views.py index a00b7799..100e47a1 100644 --- a/cart/views.py +++ b/cart/views.py @@ -19,15 +19,18 @@ from payments.models import Basket, Order from system_meta.models import Product from unified_ecommerce.constants import ( + POST_SALE_SOURCE_REDIRECT, USER_MSG_TYPE_PAYMENT_ACCEPTED, USER_MSG_TYPE_PAYMENT_CANCELLED, USER_MSG_TYPE_PAYMENT_DECLINED, USER_MSG_TYPE_PAYMENT_ERROR, USER_MSG_TYPE_PAYMENT_ERROR_UNKNOWN, ) +from unified_ecommerce.plugin_manager import get_plugin_manager from unified_ecommerce.utils import redirect_with_user_message log = logging.getLogger(__name__) +pm = get_plugin_manager() class CartView(LoginRequiredMixin, TemplateView): @@ -149,6 +152,8 @@ def post(self, request): processed_order_state = api.process_cybersource_payment_response( request, order ) + pm.hook.post_sale(order_id=order.id, source=POST_SALE_SOURCE_REDIRECT) + return self.post_checkout_redirect(processed_order_state, request) else: return self.post_checkout_redirect(order.state, request) @@ -183,5 +188,9 @@ def get(self, request): return render( request, self.template_name, - {"checkout_payload": checkout_payload, "form": checkout_payload["payload"]}, + { + "checkout_payload": checkout_payload, + "form": checkout_payload["payload"], + "debug_mode": settings.MITOL_UE_PAYMENT_INTERSTITIAL_DEBUG, + }, ) diff --git a/payments/admin.py b/payments/admin.py index a20ec7b6..b4b82420 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -75,7 +75,7 @@ def has_change_permission(self, request, obj=None): # noqa: ARG002 @display(description="Purchaser") def get_purchaser(self, obj: models.Order): """Return the purchaser information for the order""" - return f"{obj.purchaser.name} ({obj.purchaser.email})" + return f"{obj.purchaser.email}" def get_queryset(self, request): """Filter only to pending orders""" diff --git a/payments/api.py b/payments/api.py index 73db5ab0..77e6fa92 100644 --- a/payments/api.py +++ b/payments/api.py @@ -56,9 +56,10 @@ def generate_checkout_payload(request): ) for line_item in order.lines.all(): + log.debug("Adding line item %s", line_item) field_dict = line_item.product_version.field_dict system = IntegratedSystem.objects.get(pk=field_dict["system_id"]) - sku = f"{system.slug}-{field_dict['sku']}" + sku = f"{system.slug}!{field_dict['sku']}" gateway_order.items.append( GatewayCartItem( code=sku, @@ -89,6 +90,8 @@ def generate_checkout_payload(request): callback_uri = request.build_absolute_uri(reverse("checkout-result-callback")) + log.debug("Gateway order for %s: %s", order.reference_number, gateway_order) + return PaymentGateway.start_payment( settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, gateway_order, diff --git a/payments/constants.py b/payments/constants.py new file mode 100644 index 00000000..86be4dc0 --- /dev/null +++ b/payments/constants.py @@ -0,0 +1,13 @@ +""" +Constants for the payments app. +""" + +PAYMENT_HOOK_ACTION_PRE_SALE = "presale" +PAYMENT_HOOK_ACTION_POST_SALE = "postsale" +PAYMENT_HOOK_ACTION_POST_REFUND = "postrefund" + +PAYMENT_HOOK_ACTIONS = [ + PAYMENT_HOOK_ACTION_PRE_SALE, + PAYMENT_HOOK_ACTION_POST_SALE, + PAYMENT_HOOK_ACTION_POST_REFUND, +] diff --git a/payments/hooks/__init__.py b/payments/hooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/hooks/post_sale.py b/payments/hooks/post_sale.py new file mode 100644 index 00000000..a91d8733 --- /dev/null +++ b/payments/hooks/post_sale.py @@ -0,0 +1,90 @@ +"""Post-sale hook implementations for payments.""" + +import logging + +import pluggy +import requests + +from payments.constants import PAYMENT_HOOK_ACTION_POST_SALE +from payments.models import Order +from payments.serializers.v0 import WebhookBase, WebhookBaseSerializer, WebhookOrder + +hookimpl = pluggy.HookimplMarker("unified_ecommerce") + + +class PostSaleSendEmails: + """Send email when the order is fulfilled.""" + + @hookimpl + def post_sale(self, order_id, source): + """Send email when the order is fulfilled.""" + log = logging.getLogger(__name__) + + msg = "Sending email for order %s with source %s" + log.info(msg, order_id, source) + + +class IntegratedSystemWebhooks: + """Figures out what webhook endpoints to call, and calls them.""" + + def post_sale_impl(self, order_id, source): + """Call the webhook endpoints for the order.""" + + log = logging.getLogger(__name__) + + log.info( + "Calling webhook endpoints for order %s with source %s", order_id, source + ) + + order = Order.objects.prefetch_related("lines", "lines__product_version").get( + pk=order_id + ) + + systems = [ + product.system + for product in [ + line.product_version._object_version.object # noqa: SLF001 + for line in order.lines.all() + ] + ] + + for system in systems: + system_webhook_url = system.webhook_url + system_slug = system.slug + if system_webhook_url: + log.info( + ("Calling webhook endpoint %s for order %s with source %s"), + system_webhook_url, + order_id, + source, + ) + + order_info = WebhookOrder( + order=order, + lines=[ + line + for line in order.lines.all() + if line.product.system.slug == system_slug + ], + ) + + webhook_data = WebhookBase( + type=PAYMENT_HOOK_ACTION_POST_SALE, + system_key=system.api_key, + user=order.purchaser, + data=order_info, + ) + + serializer = WebhookBaseSerializer(webhook_data) + + requests.post( + system_webhook_url, + json=serializer.data, + timeout=30, + ) + + @hookimpl + def post_sale(self, order_id, source): + """Call the implementation of this, so we can test it more easily.""" + + self.post_sale_impl(order_id, source) diff --git a/payments/hooks/post_sale_test.py b/payments/hooks/post_sale_test.py new file mode 100644 index 00000000..566d7922 --- /dev/null +++ b/payments/hooks/post_sale_test.py @@ -0,0 +1,111 @@ +"""Tests for post-sale hooks.""" + +import pytest +import reversion +from reversion.models import Version + +from payments.constants import PAYMENT_HOOK_ACTION_POST_SALE +from payments.factories import LineFactory, OrderFactory +from payments.hooks.post_sale import IntegratedSystemWebhooks +from payments.models import Order +from payments.serializers.v0 import WebhookBase, WebhookBaseSerializer, WebhookOrder +from system_meta.factories import ProductFactory +from system_meta.models import IntegratedSystem +from unified_ecommerce.constants import ( + POST_SALE_SOURCE_BACKOFFICE, + POST_SALE_SOURCE_REDIRECT, +) + +pytestmark = [pytest.mark.django_db] + + +@pytest.fixture() +def fulfilled_order(): + """Create a fulfilled order.""" + + order = OrderFactory.create(state=Order.STATE.FULFILLED) + + with reversion.create_revision(): + product = ProductFactory.create() + + product_version = Version.objects.get_for_object(product).first() + LineFactory.create(order=order, product_version=product_version) + + return order + + +@pytest.mark.parametrize( + "source", [POST_SALE_SOURCE_BACKOFFICE, POST_SALE_SOURCE_REDIRECT] +) +def test_integrated_system_webhook(mocker, fulfilled_order, source): + """Test fire the webhook.""" + + mocked_request = mocker.patch("requests.post") + webhook = IntegratedSystemWebhooks() + system_id = fulfilled_order.lines.first().product_version.field_dict["system_id"] + system = IntegratedSystem.objects.get(pk=system_id) + + order_info = WebhookOrder( + order=fulfilled_order, + lines=fulfilled_order.lines.all(), + ) + + webhook_data = WebhookBase( + type=PAYMENT_HOOK_ACTION_POST_SALE, + system_key=system.api_key, + user=fulfilled_order.purchaser, + data=order_info, + ) + + serialized_webhook_data = WebhookBaseSerializer(webhook_data) + + webhook.post_sale_impl(fulfilled_order.id, source) + + mocked_request.assert_called_with( + system.webhook_url, json=serialized_webhook_data.data, timeout=30 + ) + + +@pytest.mark.parametrize( + "source", [POST_SALE_SOURCE_BACKOFFICE, POST_SALE_SOURCE_REDIRECT] +) +def test_integrated_system_webhook_multisystem(mocker, fulfilled_order, source): + """Test fire the webhook with an order with lines from >1 system.""" + + with reversion.create_revision(): + product = ProductFactory.create() + + product_version = Version.objects.get_for_object(product).first() + LineFactory.create(order=fulfilled_order, product_version=product_version) + + mocked_request = mocker.patch("requests.post") + webhook = IntegratedSystemWebhooks() + + serialized_calls = [] + + for system in IntegratedSystem.objects.all(): + order_info = WebhookOrder( + order=fulfilled_order, + lines=[ + line + for line in fulfilled_order.lines.all() + if line.product.system.slug == system.slug + ], + ) + + webhook_data = WebhookBase( + type=PAYMENT_HOOK_ACTION_POST_SALE, + system_key=system.api_key, + user=fulfilled_order.purchaser, + data=order_info, + ) + + serialized_order = WebhookBaseSerializer(webhook_data).data + serialized_calls.append( + mocker.call(system.webhook_url, json=serialized_order, timeout=30) + ) + + webhook.post_sale_impl(fulfilled_order.id, source) + + assert mocked_request.call_count == 2 + mocked_request.assert_has_calls(serialized_calls, any_order=True) diff --git a/payments/hookspecs.py b/payments/hookspecs.py new file mode 100644 index 00000000..97b68413 --- /dev/null +++ b/payments/hookspecs.py @@ -0,0 +1,36 @@ +"""Hookspecs for the payments app.""" +# ruff: noqa: ARG001 + +import pluggy + +hookspec = pluggy.HookspecMarker("unified_ecommerce") + + +@hookspec +def post_sale(order_id, source): + """ + Trigger post-sale events. + + This happens when the order has been completed, either via the browser + redirect or via the back-office webhook. The caller should specify the + source from the POST_SALE_SOURCES list (in unified_ecommerce.constants). + + Args: + order_id (int): ID of the order that has been completed. + source (str): Source of the order that has been completed; one of POST_SALE_SOURCES. + """ + + +@hookspec +def post_refund(order_id, source): + """ + Trigger post-refund events. + + This happens when the order has been refunded. These generally should just + come back from the back-office webhook but the source option is specified + in case that changes. + + Args: + order_id (int): ID of the order that has been completed. + source (str): Source of the order that has been completed; one of POST_SALE_SOURCES. + """ diff --git a/payments/models.py b/payments/models.py index 4ac9855b..35f5814e 100644 --- a/payments/models.py +++ b/payments/models.py @@ -21,7 +21,7 @@ ) User = get_user_model() -logger = logging.getLogger(__name__) +log = logging.getLogger(__name__) class Basket(TimestampedModel): @@ -40,15 +40,21 @@ def compare_to_order(self, order): if self.user != order.purchaser: return False - all_items_found = self.basket_items.count() == order.lines.count() + if self.basket_items.count() != order.lines.count(): + return False + + for basket_item in self.basket_items.all(): + found_this_one = False + + for order_item in order.lines.all(): + if order_item.product == basket_item.product: + found_this_one = True + break - if all_items_found: - for basket_item in self.basket_items.all(): - for order_item in order.lines.all(): - if order_item.product != basket_item.product: - all_items_found = False + if not found_this_one: + return False - return all_items_found + return True def get_products(self): """ @@ -144,7 +150,7 @@ class STATE: def save(self, *args, **kwargs): """Save the order.""" - logger.info("Saving order %s", self.id) + log.info("Saving order %s", self.id) # initial save in order to get primary key for new order super().save(*args, **kwargs) @@ -154,7 +160,7 @@ def save(self, *args, **kwargs): # if we don't have a generated reference number, we generate one and save again if self.reference_number is None or len(self.reference_number) == 0: - logger.info("Generating reference number for order %s", self.id) + log.info("Generating reference number for order %s", self.id) self.reference_number = self._generate_reference_number() super().save(*args, **kwargs) @@ -326,15 +332,22 @@ def _get_or_create(self, products: list[Product], user: User): # Create or get Line for each product. # Calculate the Order total based on Lines and discount. total = 0 - for i, _ in enumerate(products): - line, _ = order.lines.get_or_create( + for product_version in product_versions: + line, created = order.lines.get_or_create( order=order, + product_version=product_version, defaults={ - "product_version": product_versions[i], "quantity": 1, }, ) total += line.discounted_price + log.debug( + "%s line %s product %s", + ("Created" if created else "Updated"), + line, + product_version.field_dict["sku"], + ) + line.save() order.total_price_paid = total @@ -357,6 +370,9 @@ def create_from_basket(cls, basket: Basket): PendingOrder: the created pending order """ products = basket.get_products() + + log.debug("Products to add to order: %s", products) + return cls._get_or_create(cls, products, basket.user) @classmethod diff --git a/payments/models_test.py b/payments/models_test.py new file mode 100644 index 00000000..684a8351 --- /dev/null +++ b/payments/models_test.py @@ -0,0 +1,68 @@ +"""Tests for payment models.""" + +import pytest +import reversion + +from payments import models +from payments.factories import BasketFactory, BasketItemFactory, LineFactory +from system_meta.factories import ProductVersionFactory + +pytestmark = [pytest.mark.django_db] + + +def test_basket_compare_to_order_match(): + """ + Test that comparing an order to a basket works if they match. + + We consider the basket to match the order if it has the same number of items + and the same products attached to it. In this case, the order and basket + should match. + """ + + basket = BasketFactory.create() + with reversion.create_revision(): + BasketItemFactory.create_batch(2, basket=basket) + + order = models.PendingOrder.create_from_basket(basket) + + assert basket.compare_to_order(order) + + +@pytest.mark.parametrize( + ("add_or_del", "in_basket"), + [ + (True, False), + (True, True), + (False, True), + (False, False), + ], +) +def test_basket_compare_to_order_line_mismatch(add_or_del, in_basket): + """ + Test that comparing an order to a basket works properly. In this case, force + the basket to not compare by adding or removing a line in the Order or in + the Basket, depending. + """ + + basket = BasketFactory.create() + with reversion.create_revision(): + BasketItemFactory.create_batch(2, basket=basket) + + order = models.PendingOrder.create_from_basket(basket) + + if in_basket: + if add_or_del: + LineFactory.create( + order=order, product_version=ProductVersionFactory.create() + ) + else: + order.lines.first().delete() + elif add_or_del: + BasketItemFactory.create(basket=basket) + else: + basket.basket_items.first().delete() + + basket.refresh_from_db() + order.refresh_from_db() + + assert not basket.compare_to_order(order) diff --git a/payments/serializers/v0/__init__.py b/payments/serializers/v0/__init__.py index eb83c132..4af74bae 100644 --- a/payments/serializers/v0/__init__.py +++ b/payments/serializers/v0/__init__.py @@ -1,10 +1,58 @@ """Serializers for payments.""" +from dataclasses import dataclass + +from django.contrib.auth import get_user_model from rest_framework import serializers +from rest_framework_dataclasses.serializers import DataclassSerializer -from payments.models import Basket, BasketItem +from payments.constants import ( + PAYMENT_HOOK_ACTION_POST_SALE, + PAYMENT_HOOK_ACTIONS, +) +from payments.models import Basket, BasketItem, Line, Order from system_meta.models import Product from system_meta.serializers import ProductSerializer +from unified_ecommerce.serializers import UserSerializer + +User = get_user_model() + + +@dataclass +class WebhookOrder: + """ + Webhook event data for order-based events. + + This includes order completed and order refunded states. + """ + + order: Order + lines: Line + + +@dataclass +class WebhookCart: + """ + Webhook event data for cart-based events. + + This includes item added to cart and item removed from cart. (These are so + the integrated system can fire off enrollments when people add things to + their cart - MITx Online specifically enrolls as soon as you add to cart, + regardless of whether or not you pay, and then upgrades when you do, for + instance.) + """ + + product: Product + + +@dataclass +class WebhookBase: + """Class representing the base data that we need to post a webhook.""" + + system_key: str + type: str + user: object + data: WebhookOrder | WebhookCart class BasketItemSerializer(serializers.ModelSerializer): @@ -109,3 +157,63 @@ class Meta: "total_price", ] model = Basket + + +class LineSerializer(serializers.ModelSerializer): + """Serializes a line item for an order.""" + + product = ProductSerializer() + unit_price = serializers.DecimalField(max_digits=9, decimal_places=2) + total_price = serializers.DecimalField(max_digits=9, decimal_places=2) + + class Meta: + """Meta options for LineSerializer""" + + fields = [ + "id", + "quantity", + "item_description", + "unit_price", + "total_price", + "product", + ] + model = Line + + +class WebhookOrderDataSerializer(DataclassSerializer): + """Serializes order data for submission to the webhook.""" + + reference_number = serializers.CharField(source="order.reference_number") + total_price_paid = serializers.DecimalField( + source="order.total_price_paid", max_digits=9, decimal_places=2 + ) + state = serializers.CharField(source="order.state") + lines = LineSerializer(many=True) + + class Meta: + """Meta options for WebhookOrderDataSerializer""" + + dataclass = WebhookOrder + + +class WebhookBaseSerializer(DataclassSerializer): + """Base serializer for webhooks.""" + + system_key = serializers.CharField() + type = serializers.ChoiceField(choices=PAYMENT_HOOK_ACTIONS) + user = UserSerializer() + data = serializers.SerializerMethodField() + + def get_data(self, instance): + """Resolve and return the proper serializer for the data field.""" + + if instance.type == PAYMENT_HOOK_ACTION_POST_SALE: + return WebhookOrderDataSerializer(instance.data).data + + error_msg = "Invalid webhook type %s" + raise ValueError(error_msg, instance.type) + + class Meta: + """Meta options for WebhookBaseSerializer""" + + dataclass = WebhookBase diff --git a/poetry.lock b/poetry.lock index 4837e149..5e108029 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "amqp" @@ -1282,6 +1282,25 @@ files = [ [package.dependencies] django = ">=4.2" +[[package]] +name = "djangorestframework-dataclasses" +version = "1.3.1" +description = "A dataclasses serializer for Django REST Framework" +optional = false +python-versions = ">=3.7" +files = [ + {file = "djangorestframework-dataclasses-1.3.1.tar.gz", hash = "sha256:d3796b5ce3f7266d525493c557ce7df9ffeae4367006250298ea4d94da4106c4"}, + {file = "djangorestframework_dataclasses-1.3.1-py3-none-any.whl", hash = "sha256:ca1aa1ca99b5306af874376f37355593bb3d1ac7d658d54e2790f9b303968065"}, +] + +[package.dependencies] +django = ">=2.0" +djangorestframework = ">=3.9" + +[package.extras] +dev = ["django-stubs", "djangorestframework-stubs", "mypy (>=1.5.1,<1.6.0)"] +test = ["coverage[toml]", "tox"] + [[package]] name = "dparse" version = "0.6.3" @@ -4126,4 +4145,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.11.0" -content-hash = "4e547569aee2e5f6b5210c679d32c39f4bf9c73885a7db91838cffe9998a8c2f" +content-hash = "f3c16a31a913537d8033660c8343c07fbf33db39c4dbc9d6a0869a944ed2730e" diff --git a/pyproject.toml b/pyproject.toml index 15d4b9c2..ad8e30d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ python-slugify = "^8.0.1" django-oauth-toolkit = "^2.3.0" requests-oauthlib = "^1.3.1" oauthlib = "^3.2.2" +djangorestframework-dataclasses = "^1.3.1" [tool.poetry.group.dev.dependencies] bpython = "^0.24" diff --git a/scripts/bootstrap_apisix-home-keycloak.sh b/scripts/bootstrap_apisix-home-keycloak.sh new file mode 100755 index 00000000..5a3acec7 --- /dev/null +++ b/scripts/bootstrap_apisix-home-keycloak.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Bootstraps a local APISIX instance. + +# Uncomment and fill these in. +APISIX_ROOT=http://kc.odl.local:9180 +API_KEY=edd1c9f034335f136f87ad84b625c8f1 +OIDC_REALM=ol-local +CLIENT_ID=apisix +CLIENT_SECRET=HckCZXToXfaetbBx0Fo3xbjnC468oMi4 +DISCOVERY_URL=http://kc.odl.local:7080/realms/ol-local/.well-known/openid-configuration + +# Define upstream connection + +curl "${APISIX_ROOT}/apisix/admin/upstreams/2" \ + -H "X-API-KEY: $API_KEY" -X PUT -d ' +{ + "type": "roundrobin", + "nodes": { + "nginx:8073": 1 + } +}' + +# Define the Universal Ecommerce unauthenticated route +# This is stuff that doesn't need a session - static resources, and the checkout result API + +postbody=$( + cat <