From ec9a2f1321b5975c36141af84c0154e33528a81d Mon Sep 17 00:00:00 2001 From: James Kachel Date: Tue, 21 Jul 2026 13:46:05 -0500 Subject: [PATCH 1/3] Remove auth requirement for post-checkout page; add backoffice API logs (#3772) --- ecommerce/views/legacy/__init__.py | 41 +++++++++--- ecommerce/views/legacy/views_test.py | 99 +++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 11 deletions(-) diff --git a/ecommerce/views/legacy/__init__.py b/ecommerce/views/legacy/__init__.py index a4e76eeda0..9b61675ae8 100644 --- a/ecommerce/views/legacy/__init__.py +++ b/ecommerce/views/legacy/__init__.py @@ -798,6 +798,9 @@ class CheckoutCallbackView(View): Handle a checkout cancellation or receipt """ + authentication_classes = [] # disables authentication + permission_classes = [] # disables permission + def __init__(self, *args, **kwargs): # noqa: ARG002 self.logger = logging.getLogger(__name__) @@ -836,15 +839,10 @@ def post_checkout_redirect(self, order_state, order, request): }, ) else: - if not PaymentGateway.validate_processor_response( + processor_response = PaymentGateway.get_formatted_response( settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, request - ): - log.info("Could not validate payment response for order") - else: - processor_response = PaymentGateway.get_formatted_response( - settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, request - ) - log.error( + ) + self.logger.error( "Checkout callback unknown error for transaction_id %s, state %s, reason_code %s, message %s, and ProcessorResponse %s", processor_response.transaction_id, order_state, @@ -868,6 +866,23 @@ def post(self, request, *args, **kwargs): # noqa: ARG002 3. Perform any enrollments, account status changes, etc. """ + if not PaymentGateway.validate_processor_response( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, request + ): + user_email = ( + "anonymous" + if not request.user or request.user.is_anomymous + else request.user.email + ) + + self.logger.error( + "CheckoutCallbackView: unable to validate the processor payload for user %s", + user_email, + ) + return Response( + "Unable to validate request.", status=status.HTTP_403_UNAUTHORIZED + ) + with transaction.atomic(): order = api.get_order_from_cybersource_payment_response(request) if order is None: @@ -919,6 +934,16 @@ def post(self, request, *args, **kwargs): # noqa: ARG002 This endpoint is called by Cybersource as a server-to-server call to respond with the payment details. """ + if not PaymentGateway.validate_processor_response( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, request + ): + log.error( + "BackofficeCallbackView: unable to validate the processor payload from CyberSource" + ) + return Response( + "Unable to validate request.", status=status.HTTP_401_UNAUTHORIZED + ) + with transaction.atomic(): order = api.get_order_from_cybersource_payment_response(request) diff --git a/ecommerce/views/legacy/views_test.py b/ecommerce/views/legacy/views_test.py index a3dcc289de..dbba694283 100644 --- a/ecommerce/views/legacy/views_test.py +++ b/ecommerce/views/legacy/views_test.py @@ -10,7 +10,9 @@ from django.test import Client, RequestFactory from django.urls import reverse from mitol.common.utils.datetime import now_in_utc +from mitol.payment_gateway.api import PaymentGateway from rest_framework import status +from reversion.models import Version from b2b.factories import ContractPageFactory from courses.factories import CourseRunFactory, ProgramFactory, ProgramRunFactory @@ -22,12 +24,14 @@ PAYMENT_TYPE_CUSTOMER_SUPPORT, PAYMENT_TYPE_FINANCIAL_ASSISTANCE, REDEMPTION_TYPE_ONE_TIME, + REFERENCE_NUMBER_PREFIX, ) from ecommerce.discounts import DiscountType from ecommerce.factories import ( BasketFactory, BasketItemFactory, DiscountFactory, + LineFactory, ProductFactory, UnlimitedUseDiscountFactory, ) @@ -1273,7 +1277,6 @@ def test_checkout_api_result( # noqa: PLR0913 @pytest.mark.skip_nplusone_check def test_checkout_api_result_verification_failure( - user_client, api_client, mocker, user, @@ -1301,8 +1304,8 @@ def test_checkout_api_result_verification_failure( resp = api_client.post(reverse("checkout_result_api"), payload) - # checkout_result_api will always respond with a 403 if validate_processor_response returns False - assert resp.status_code == 403 + # the validation gets checked sooner now, and returns a 401 + assert resp.status_code == 401 @pytest.mark.skip_nplusone_check @@ -1561,3 +1564,93 @@ def test_program_product_purchasing(user, user_drf_client): assert ProgramEnrollment.objects.filter( user=user, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE ).exists() + + +def test_backoffice_callback_invalid_payload(mocker, client): + """Test that the backoffice API fails out early if the payload is invalid.""" + + payload = { + "signature": "123456", + "waffles": "", + "signed_field_names": "waffles", + } + + mocked_get_order = mocker.patch( + "ecommerce.api.get_order_from_cybersource_payment_response" + ) + + response = client.post(reverse("checkout_result_api"), payload) + + assert response.status_code == 401 + mocked_get_order.assert_not_called() + + +def test_backoffice_callback_bad_order(mocker, client, settings): + """Test that the backoffice API returns 404 if there's no order.""" + + last_order = Order.objects.order_by("pk").last() + max_order_id = (last_order.id if last_order else 0) + 200 + + payload = { + "req_consumer_id": "bob@doe.local", + "req_customer_ip_address": "127.0.0.1", + "req_reference_number": f"{REFERENCE_NUMBER_PREFIX}{settings.ENVIRONMENT}-{max_order_id}", + "req_line_item_count": 0, + } + + mocked_processor = mocker.patch( + "ecommerce.api.process_cybersource_payment_response" + ) + + payload = PaymentGateway.get_gateway_class( # noqa: SLF001 + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY + )._sign_cybersource_payload(payload) + + response = client.post(reverse("checkout_result_api"), payload) + + assert response.status_code == 404 + mocked_processor.assert_not_called() + + +def test_backoffice_callback_good_order(mocker, client, settings): + """Test that the backoffice API fullfils the order if it's good.""" + + with reversion.create_revision(): + product = ProductFactory.create() + + product_version = Version.objects.get_for_object(product).first() + + order_line = LineFactory.create(product_version=product_version) + order = order_line.order + + # Ultimately, don't really care that much about the payload + # A successful one is fine - fully testing fulfillment is done elsewhere + payload = { + "req_consumer_id": "bob@doe.local", + "req_customer_ip_address": "127.0.0.1", + "req_reference_number": order.reference_number, + "req_line_item_count": 1, + "req_item_0_code": order_line.courseware, + "req_item_0_name": order_line.courseware, + "req_item_0_sku": order_line.product.id, + "req_item_0_unit_price": order_line.total_price, + "req_item_0_tax_amount": 0, + "req_item_0_quantity": 1, + "message": "Completed", + "reason_code": "", + "transaction_id": "12345abcde", + "decision": "ACCEPT", + } + + mocked_processor = mocker.patch( + "ecommerce.api.process_cybersource_payment_response" + ) + + payload = PaymentGateway.get_gateway_class( # noqa: SLF001 + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY + )._sign_cybersource_payload(payload) + + response = client.post(reverse("checkout_result_api"), payload) + + assert response.status_code == 200 + mocked_processor.assert_called() From 6e756b074a9737559b6a3894e59421ed49714a9b Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Wed, 22 Jul 2026 11:20:02 +0500 Subject: [PATCH 2/3] fix: add 'stay update' boolean field for course page (#3768) Co-authored-by: Ahtesham Quraish Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- ...6_coursepage_show_stay_updated_and_more.py | 30 +++++++++++++++++++ cms/models.py | 18 ++++++++++- cms/wagtail_api/schema/serializers.py | 2 ++ openapi/specs/v0.yaml | 12 ++++++++ openapi/specs/v1.yaml | 12 ++++++++ openapi/specs/v2.yaml | 12 ++++++++ 6 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 cms/migrations/0066_coursepage_show_stay_updated_and_more.py diff --git a/cms/migrations/0066_coursepage_show_stay_updated_and_more.py b/cms/migrations/0066_coursepage_show_stay_updated_and_more.py new file mode 100644 index 0000000000..63a6658b8c --- /dev/null +++ b/cms/migrations/0066_coursepage_show_stay_updated_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.15 on 2026-07-21 06:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0065_alter_certificatepage_product_name"), + ] + + operations = [ + migrations.AddField( + model_name="coursepage", + name="show_stay_updated", + field=models.BooleanField( + default=False, + help_text="If true, show the 'Stay Updated' sign-up form on this course page.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="show_stay_updated", + field=models.BooleanField( + default=False, + help_text="If true, show the 'Stay Updated' sign-up form on this program page.", + null=True, + ), + ), + ] diff --git a/cms/models.py b/cms/models.py index f7722e318b..699f8144dd 100644 --- a/cms/models.py +++ b/cms/models.py @@ -1431,6 +1431,11 @@ class CoursePage(ProductPage): null=True, help_text="If true, allow the AI chatbots to ingest the course's content files.", ) + show_stay_updated = models.BooleanField( + default=False, + null=True, + help_text="If true, show the 'Stay Updated' sign-up form on this course page.", + ) template = "product_page.html" search_fields = Page.search_fields + [ # noqa: RUF005 @@ -1447,6 +1452,7 @@ class CoursePage(ProductPage): *ProductPage.content_panels, FieldPanel("include_in_learn_catalog"), FieldPanel("ingest_content_files_for_ai"), + FieldPanel("show_stay_updated"), ] api_fields = [ *ProductPage.api_fields, @@ -1454,6 +1460,7 @@ class CoursePage(ProductPage): APIField("topic_list"), APIField("include_in_learn_catalog"), APIField("ingest_content_files_for_ai"), + APIField("show_stay_updated"), ] @cached_property @@ -1572,6 +1579,11 @@ class ProgramPage(ProductPage): null=True, help_text="If true, Learn should include this in its catalog.", ) + show_stay_updated = models.BooleanField( + default=False, + null=True, + help_text="If true, show the 'Stay Updated' sign-up form on this program page.", + ) list_price = models.DecimalField( max_digits=10, decimal_places=2, @@ -1595,13 +1607,17 @@ class ProgramPage(ProductPage): FieldPanel("list_price"), ] + ProductPage.content_panels - + [FieldPanel("include_in_learn_catalog")] + + [ + FieldPanel("include_in_learn_catalog"), + FieldPanel("show_stay_updated"), + ] ) api_fields = [ *ProductPage.api_fields, APIField("program_details"), APIField("list_price"), APIField("include_in_learn_catalog"), + APIField("show_stay_updated"), ] @property diff --git a/cms/wagtail_api/schema/serializers.py b/cms/wagtail_api/schema/serializers.py index 80710a8bed..b9cd6310b4 100644 --- a/cms/wagtail_api/schema/serializers.py +++ b/cms/wagtail_api/schema/serializers.py @@ -198,6 +198,7 @@ class Meta: "topic_list", "include_in_learn_catalog", "ingest_content_files_for_ai", + "show_stay_updated", "how_youll_learn", ] @@ -257,6 +258,7 @@ class Meta: "faculty", "certificate_page", "program_details", + "show_stay_updated", "how_youll_learn", ] diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index aa600e0302..cb1efb9a4a 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -5172,6 +5172,11 @@ components: nullable: true description: If true, allow the AI chatbots to ingest the course's content files. + show_stay_updated: + type: boolean + nullable: true + description: If true, show the 'Stay Updated' sign-up form on this course + page. how_youll_learn: type: array items: @@ -5200,6 +5205,7 @@ components: - min_weeks - prerequisites - price + - show_stay_updated - title - topic_list - video_url @@ -8610,6 +8616,11 @@ components: $ref: '#/components/schemas/CertificatePage' program_details: $ref: '#/components/schemas/V2Program' + show_stay_updated: + type: boolean + nullable: true + description: If true, show the 'Stay Updated' sign-up form on this program + page. how_youll_learn: type: array items: @@ -8636,6 +8647,7 @@ components: - prerequisites - price - program_details + - show_stay_updated - title - video_url - what_you_learn diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 0b9bdc94f9..ea37999834 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -5172,6 +5172,11 @@ components: nullable: true description: If true, allow the AI chatbots to ingest the course's content files. + show_stay_updated: + type: boolean + nullable: true + description: If true, show the 'Stay Updated' sign-up form on this course + page. how_youll_learn: type: array items: @@ -5200,6 +5205,7 @@ components: - min_weeks - prerequisites - price + - show_stay_updated - title - topic_list - video_url @@ -8610,6 +8616,11 @@ components: $ref: '#/components/schemas/CertificatePage' program_details: $ref: '#/components/schemas/V2Program' + show_stay_updated: + type: boolean + nullable: true + description: If true, show the 'Stay Updated' sign-up form on this program + page. how_youll_learn: type: array items: @@ -8636,6 +8647,7 @@ components: - prerequisites - price - program_details + - show_stay_updated - title - video_url - what_you_learn diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index f2e06c4322..9f7511d1f3 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -5172,6 +5172,11 @@ components: nullable: true description: If true, allow the AI chatbots to ingest the course's content files. + show_stay_updated: + type: boolean + nullable: true + description: If true, show the 'Stay Updated' sign-up form on this course + page. how_youll_learn: type: array items: @@ -5200,6 +5205,7 @@ components: - min_weeks - prerequisites - price + - show_stay_updated - title - topic_list - video_url @@ -8610,6 +8616,11 @@ components: $ref: '#/components/schemas/CertificatePage' program_details: $ref: '#/components/schemas/V2Program' + show_stay_updated: + type: boolean + nullable: true + description: If true, show the 'Stay Updated' sign-up form on this program + page. how_youll_learn: type: array items: @@ -8636,6 +8647,7 @@ components: - prerequisites - price - program_details + - show_stay_updated - title - video_url - what_you_learn From 2f39bf911233b54a315e9b3795b84033787415c7 Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 22 Jul 2026 06:23:06 +0000 Subject: [PATCH 3/3] Release 1.160.4 --- RELEASE.rst | 6 ++++++ main/settings.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 260bb6422c..b91cd17c64 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,12 @@ Release Notes ============= +Version 1.160.4 +--------------- + +- fix: add 'stay update' boolean field for course page (#3768) +- Remove auth requirement for post-checkout page; add backoffice API logs (#3772) + Version 1.160.3 (Released July 21, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index b5a9e365d1..1a5ed27f16 100644 --- a/main/settings.py +++ b/main/settings.py @@ -37,7 +37,7 @@ from main.sentry import init_sentry from openapi.settings_spectacular import open_spectacular_settings -VERSION = "1.160.3" +VERSION = "1.160.4" log = logging.getLogger()