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 <