Skip to content

Commit

Permalink
Add webhook event for add-to-basket (#185)
Browse files Browse the repository at this point in the history
  • Loading branch information
jkachel authored Dec 18, 2024
1 parent 8b9b4c9 commit e1b6e5b
Show file tree
Hide file tree
Showing 10 changed files with 819 additions and 452 deletions.
114 changes: 112 additions & 2 deletions payments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
from mitol.payment_gateway.api import Order as GatewayOrder
from mitol.payment_gateway.api import PaymentGateway, ProcessorResponse

from payments.constants import (
PAYMENT_HOOK_ACTION_POST_SALE,
PAYMENT_HOOK_ACTION_PRE_SALE,
)
from payments.dataclasses import CustomerLocationMetadata
from payments.exceptions import (
PaymentGatewayError,
Expand All @@ -33,7 +37,13 @@
PendingOrder,
TaxRate,
)
from payments.tasks import send_post_sale_webhook
from payments.serializers.v0 import (
WebhookBase,
WebhookBaseSerializer,
WebhookBasket,
WebhookOrder,
)
from payments.tasks import dispatch_webhook
from payments.utils import parse_supplied_date
from system_meta.models import IntegratedSystem, Product
from unified_ecommerce.constants import (
Expand Down Expand Up @@ -438,6 +448,58 @@ def check_and_process_pending_orders_for_resolution(refnos=None):
return (fulfilled_count, cancel_count, error_count)


def send_post_sale_webhook(system_id, order_id, source):
"""
Actually send the webhook some data for a post-sale event.
This is split out so we can queue the webhook requests individually.
"""

order = Order.objects.get(pk=order_id)
system = IntegratedSystem.objects.get(pk=system_id)

system_webhook_url = system.webhook_url
if system_webhook_url:
log.info(
(
"send_post_sale_webhook: Calling webhook endpoint %s for order %s "
"with source %s"
),
system_webhook_url,
order.reference_number,
source,
)
else:
log.warning(
(
"send_post_sale_webhook: No webhook URL set for system %s, skipping"
"for order %s"
),
system.slug,
order.reference_number,
)
return

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_slug=system.slug,
system_key=system.api_key,
user=order.purchaser,
data=order_info,
)

dispatch_webhook.delay(system_webhook_url, WebhookBaseSerializer(webhook_data).data)


def process_post_sale_webhooks(order_id, source):
"""
Send data to the webhooks for post-sale events.
Expand All @@ -464,7 +526,55 @@ def process_post_sale_webhooks(order_id, source):
log.warning("No webhook URL specified for system %s", system.slug)
continue

send_post_sale_webhook.delay(system.id, order.id, source)
send_post_sale_webhook(system.id, order.id, source)


def send_pre_sale_webhook(basket, product, action):
"""
Send the webhook some data for a pre-sale event.
This happens when a user adds an product to the cart.
Args:
- basket (Basket): the basket to work with
- product (Product): the product being added/removed
- action (WebhookBasketAction): The action being taken
"""

system = basket.integrated_system

basket_info = WebhookBasket(
product=product,
action=action,
)

system_webhook_url = system.webhook_url
if system_webhook_url:
log.info(
"send_pre_sale_webhook: Calling webhook endpoint %s for %s",
system_webhook_url,
basket_info,
)
else:
log.warning(
(
"send_pre_sale_webhook: No webhook URL set for system %s, skipping"
"for event %s"
),
system.slug,
basket_info,
)
return

webhook_data = WebhookBase(
type=PAYMENT_HOOK_ACTION_PRE_SALE,
system_slug=system.slug,
system_key=system.api_key,
user=basket.user,
data=basket_info,
)

dispatch_webhook.delay(system_webhook_url, WebhookBaseSerializer(webhook_data).data)


def get_auto_apply_discounts_for_basket(basket_id: int) -> QuerySet[Discount]:
Expand Down
68 changes: 51 additions & 17 deletions payments/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@
process_cybersource_payment_response,
process_post_sale_webhooks,
refund_order,
send_pre_sale_webhook,
)
from payments.constants import (
PAYMENT_HOOK_ACTION_POST_SALE,
PAYMENT_HOOK_ACTION_PRE_SALE,
)
from payments.constants import PAYMENT_HOOK_ACTION_POST_SALE
from payments.exceptions import PaymentGatewayError, PaypalRefundError
from payments.factories import (
BasketFactory,
Expand All @@ -38,7 +42,13 @@
Order,
Transaction,
)
from payments.serializers.v0 import WebhookBase, WebhookBaseSerializer, WebhookOrder
from payments.serializers.v0 import (
WebhookBase,
WebhookBaseSerializer,
WebhookBasket,
WebhookBasketAction,
WebhookOrder,
)
from system_meta.factories import ProductFactory
from system_meta.models import IntegratedSystem
from unified_ecommerce.constants import (
Expand Down Expand Up @@ -591,10 +601,10 @@ def test_check_and_process_pending_orders_for_resolution(mocker, test_type):
@pytest.mark.parametrize(
"source", [POST_SALE_SOURCE_BACKOFFICE, POST_SALE_SOURCE_REDIRECT]
)
def test_integrated_system_webhook(mocker, fulfilled_complete_order, source):
"""Test fire the webhook."""
def test_post_sale_webhook(mocker, fulfilled_complete_order, source):
"""Test fire the post-sale webhook."""

mocked_request = mocker.patch("requests.post")
mocked_task = mocker.patch("payments.tasks.dispatch_webhook.delay")
system_id = fulfilled_complete_order.lines.first().product_version.field_dict[
"system_id"
]
Expand All @@ -607,6 +617,7 @@ def test_integrated_system_webhook(mocker, fulfilled_complete_order, source):

webhook_data = WebhookBase(
type=PAYMENT_HOOK_ACTION_POST_SALE,
system_slug=system.slug,
system_key=system.api_key,
user=fulfilled_complete_order.purchaser,
data=order_info,
Expand All @@ -616,18 +627,42 @@ def test_integrated_system_webhook(mocker, fulfilled_complete_order, source):

process_post_sale_webhooks(fulfilled_complete_order.id, source)

mocked_request.assert_called_with(
system.webhook_url, json=serialized_webhook_data.data, timeout=30
mocked_task.assert_called_with(system.webhook_url, serialized_webhook_data.data)


def test_pre_sale_webhook(mocker, user, products):
"""Test that the pre-sale webhook triggers with the right data."""

mocked_task = mocker.patch("payments.tasks.dispatch_webhook.delay")

basket = BasketItemFactory.create(product=products[0], basket__user=user).basket
system = basket.integrated_system

order_info = WebhookBasket(
product=products[0],
action=WebhookBasketAction.ADD,
)

webhook_data = WebhookBase(
type=PAYMENT_HOOK_ACTION_PRE_SALE,
system_slug=system.slug,
system_key=system.api_key,
user=user,
data=order_info,
)

serialized_webhook_data = WebhookBaseSerializer(webhook_data)

send_pre_sale_webhook(basket, products[0], WebhookBasketAction.ADD)

mocked_task.assert_called_with(system.webhook_url, serialized_webhook_data.data)


@pytest.mark.parametrize(
"source", [POST_SALE_SOURCE_BACKOFFICE, POST_SALE_SOURCE_REDIRECT]
)
def test_integrated_system_webhook_multisystem(
mocker, fulfilled_complete_order, source
):
"""Test fire the webhook with an order with lines from >1 system."""
def test_post_sale_webhook_multisystem(mocker, fulfilled_complete_order, source):
"""Test fire the post-sale webhook with an order with lines from >1 system."""

with reversion.create_revision():
product = ProductFactory.create()
Expand All @@ -639,7 +674,7 @@ def test_integrated_system_webhook_multisystem(
discounted_price=product_version.field_dict["price"],
)

mocked_request = mocker.patch("requests.post")
mocked_task = mocker.patch("payments.tasks.dispatch_webhook.delay")

serialized_calls = []

Expand All @@ -655,20 +690,19 @@ def test_integrated_system_webhook_multisystem(

webhook_data = WebhookBase(
type=PAYMENT_HOOK_ACTION_POST_SALE,
system_slug=system.slug,
system_key=system.api_key,
user=fulfilled_complete_order.purchaser,
data=order_info,
)

serialized_order = WebhookBaseSerializer(webhook_data).data
serialized_calls.append(
mocker.call(system.webhook_url, json=serialized_order, timeout=30)
)
serialized_calls.append(mocker.call(system.webhook_url, serialized_order))

process_post_sale_webhooks(fulfilled_complete_order.id, source)

assert mocked_request.call_count == 2
mocked_request.assert_has_calls(serialized_calls, any_order=True)
assert mocked_task.call_count == 2
mocked_task.assert_has_calls(serialized_calls, any_order=True)


def test_get_auto_apply_discount_for_basket_auto_discount_exists_for_integrated_system():
Expand Down
2 changes: 2 additions & 0 deletions payments/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
PAYMENT_HOOK_ACTION_PRE_SALE = "presale"
PAYMENT_HOOK_ACTION_POST_SALE = "postsale"
PAYMENT_HOOK_ACTION_POST_REFUND = "postrefund"
PAYMENT_HOOK_ACTION_TEST = "test"

PAYMENT_HOOK_ACTIONS = [
PAYMENT_HOOK_ACTION_PRE_SALE,
PAYMENT_HOOK_ACTION_POST_SALE,
PAYMENT_HOOK_ACTION_POST_REFUND,
PAYMENT_HOOK_ACTION_TEST,
]

GEOLOCATION_TYPE_PROFILE = "profile"
Expand Down
21 changes: 21 additions & 0 deletions payments/hooks/basket_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,24 @@ def taxable_check(self, request, basket, basket_item): # noqa: ARG002
)

check_taxable(basket)

@hookimpl(specname="basket_add", trylast=True)
def notify_integrated_system(self, request, basket, basket_item): # noqa: ARG002
"""
Notify the integrated system of the basket update.
Some integrated systems take action when the user adds items to the
basket (to add things like audit enrollments, etc.). This hook will send
the same data that would get sent from the post_sale hook so the
integrated system can do what it needs to.
Args:
- request (HttpRequest): the current request
- basket (Basket): the current basket
- basket_item (Product): the item to add to the basket; ignored
"""

from payments.api import send_pre_sale_webhook
from payments.serializers.v0 import WebhookBasketAction

send_pre_sale_webhook(basket, basket_item, WebhookBasketAction.ADD)
Loading

0 comments on commit e1b6e5b

Please sign in to comment.