Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -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)
---------------

Expand Down
30 changes: 30 additions & 0 deletions cms/migrations/0066_coursepage_show_stay_updated_and_more.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
18 changes: 17 additions & 1 deletion cms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1447,13 +1452,15 @@ 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,
APIField("course_details"),
APIField("topic_list"),
APIField("include_in_learn_catalog"),
APIField("ingest_content_files_for_ai"),
APIField("show_stay_updated"),
]

@cached_property
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cms/wagtail_api/schema/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ class Meta:
"topic_list",
"include_in_learn_catalog",
"ingest_content_files_for_ai",
"show_stay_updated",
"how_youll_learn",
]

Expand Down Expand Up @@ -257,6 +258,7 @@ class Meta:
"faculty",
"certificate_page",
"program_details",
"show_stay_updated",
"how_youll_learn",
]

Expand Down
41 changes: 33 additions & 8 deletions ecommerce/views/legacy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
99 changes: 96 additions & 3 deletions ecommerce/views/legacy/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
2 changes: 1 addition & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
12 changes: 12 additions & 0 deletions openapi/specs/v0.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -5200,6 +5205,7 @@ components:
- min_weeks
- prerequisites
- price
- show_stay_updated
- title
- topic_list
- video_url
Expand Down Expand Up @@ -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:
Expand All @@ -8636,6 +8647,7 @@ components:
- prerequisites
- price
- program_details
- show_stay_updated
- title
- video_url
- what_you_learn
Expand Down
Loading