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
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ repos:
- id: actionlint
name: actionlint
description: Runs actionlint to lint GitHub Actions workflow files
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.29.0
hooks:
- id: zizmor
args: [--no-progress, --min-severity=high, --min-confidence=medium]
- repo: local
hooks:
- id: drf-serializer-orm-check
Expand Down
7 changes: 7 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Release Notes
=============

Version 1.162.3
---------------

- Strip angle brackets (#3825)
- Add zizmor pre-commit hook and 7-day uv dependency delay (#3808)
- Use CMS Certificate Title for program verifiable credentials (#3698)

Version 1.162.2 (Released August 06, 2026)
---------------

Expand Down
9 changes: 8 additions & 1 deletion b2b/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,14 @@ def send_email_helper( # noqa: PLR0913
== "anymail.backends.mailgun.EmailBackend"
):
recipient_status = message.anymail_status.recipients.get(email)
message_id = recipient_status.message_id if recipient_status else None
# Message ID is in the following format when pulled from anymail
# '<20260806133209.67c51081a4f1c478@mitxonline-rc-mail.mitxonline.mit.edu>'
# The webhook doesn't have the leading or trailing angle brackets, so we'll remove those
message_id = (
recipient_status.message_id.strip("<>")
if recipient_status
else None
Comment on lines +62 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The code may raise an AttributeError by calling .strip() on recipient_status.message_id without first checking if message_id is None.
Severity: MEDIUM

Suggested Fix

Add a check to ensure recipient_status.message_id is not None before attempting to call .strip() on it. For example: message_id = recipient_status.message_id.strip("<>") if recipient_status and recipient_status.message_id else None.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: b2b/mail.py#L62-L65

Potential issue: The code at `b2b/mail.py:62~65` attempts to process an email message ID
by calling `.strip("<>")` on `recipient_status.message_id`. While the code checks if
`recipient_status` exists, it does not verify if `recipient_status.message_id` is `None`
before calling the string method. According to Anymail documentation, it is possible for
`message_id` to be `None` even when a `recipient_status` object is present, particularly
if the send operation fails in a specific way. If this scenario occurs, the code will
raise an `AttributeError`, which will be caught by the surrounding `try...except` block,
causing the function to silently fail and return `None`. This could lead to lost
tracking of email delivery status.

Did we get this right? 👍 / 👎 to inform future reviews.

)
else:
message_id = str(uuid.uuid4())

Expand Down
4 changes: 3 additions & 1 deletion courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1779,7 +1779,9 @@ def get_verifiable_credentials_payload(
program = certificate.program
program_page = program.program_page
url = get_learn_product_url("programs", program.readable_id)
certificate_name = certificate.program.title
certificate_name = (
certificate_page.product_name or ""
).strip() or certificate.program.title
activity_start_date = ProgramEnrollment.all_objects.get(
user_id=certificate.user_id, program=program
).created_on.strftime("%Y-%m-%dT%H:%M:%SZ")
Expand Down
42 changes: 40 additions & 2 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3377,6 +3377,7 @@ def test_program_certificate_verifiable_credentials(
mock_certificate_page = Mock()
mock_certificate_page.verifiable_credential_criteria = "mock_credential_data"
mock_certificate_page.should_provision_verifiable_credential = True
mock_certificate_page.product_name = "Test Program Certificate"
mocker.patch("courses.api.get_certificate_page", return_value=mock_certificate_page)
courses = CourseFactory.create_batch(3)
course_runs = CourseRunFactory.create_batch(3, course=factory.Iterator(courses))
Expand Down Expand Up @@ -3588,6 +3589,9 @@ def test_program_certificate_verifiable_credentials_signing_payload(

mock_certificate_page = Mock()
mock_certificate_page.verifiable_credential_criteria = "mock_credential_data"
# The verifiable credential name should come from the CMS "Certificate Title"
# (product_name), not the program title.
mock_certificate_page.product_name = "Universal AI"
payload = get_verifiable_credentials_payload(program_cert, mock_certificate_page)

# Assert the expected payload structure
Expand Down Expand Up @@ -3630,8 +3634,8 @@ def test_program_certificate_verifiable_credentials_signing_payload(
"criteria": {
"narrative": mock_certificate_page.verifiable_credential_criteria
},
"description": "Jane Smith has successfully completed all modules and earned a Program Certificate in Data Science MicroMasters.",
"name": "Data Science MicroMasters",
"description": "Jane Smith has successfully completed all modules and earned a Program Certificate in Universal AI.",
"name": "Universal AI",
"image": {
"id": "https://example.com/program-thumbnail.jpg",
"type": "Image",
Expand All @@ -3644,6 +3648,40 @@ def test_program_certificate_verifiable_credentials_signing_payload(
assert payload == expected_payload


@pytest.mark.parametrize("product_name", ["", " "])
@patch("courses.api.ProgramEnrollment.all_objects.get")
@patch("courses.api.get_thumbnail_url")
def test_program_verifiable_credential_name_falls_back_to_program_title(
mock_get_thumbnail_url, mock_enrollment_get, product_name, settings, mocker
):
"""The VC name falls back to the program title when product_name is blank."""
mocker.patch("hubspot_sync.task_helpers.sync_hubspot_user")
mocker.patch("hubspot_sync.api.upsert_custom_properties")

mock_enrollment = Mock()
mock_enrollment.created_on = datetime(
2024, 2, 20, 14, 45, 0, tzinfo=ZoneInfo("UTC")
)
mock_enrollment_get.return_value = mock_enrollment
mock_get_thumbnail_url.return_value = ""

settings.ENVIRONMENT = "production"

program_cert = ProgramCertificateFactory.create()
program_cert.program.title = "Data Science MicroMasters"
program_cert.program.save()

mock_certificate_page = Mock()
mock_certificate_page.verifiable_credential_criteria = "mock_credential_data"
mock_certificate_page.product_name = product_name

payload = get_verifiable_credentials_payload(program_cert, mock_certificate_page)

achievement = payload["credentialSubject"]["achievement"]
assert achievement["name"] == "Data Science MicroMasters"
assert "Data Science MicroMasters" in achievement["description"]


@pytest.mark.parametrize(
"keep_failed_enrollments,flag_enabled,expected_behavior", # noqa: PT006
[
Expand Down
2 changes: 1 addition & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from main.sentry import init_sentry
from openapi.settings_spectacular import open_spectacular_settings

VERSION = "1.162.2"
VERSION = "1.162.3"

log = logging.getLogger()

Expand Down
49 changes: 49 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,55 @@ dev = [
[tool.uv]
package = false
default-groups = "all"
exclude-newer = "7d"
required-version = ">=0.9.17"

[tool.uv.exclude-newer-package]
ol-concourse = "0d"
django-aqueduct = "0d"
edx-sysadmin = "0d"
edx-username-changer = "0d"
ol-openedx-ai-static-translations = "0d"
ol-openedx-auto-select-language = "0d"
ol-openedx-canvas-integration = "0d"
ol-openedx-chat = "0d"
ol-openedx-chat-xblock = "0d"
ol-openedx-checkout-external = "0d"
ol-openedx-course-export = "0d"
ol-openedx-course-outline-api = "0d"
ol-openedx-course-structure-api = "0d"
ol-openedx-course-sync = "0d"
ol-openedx-course-translations = "0d"
ol-openedx-events-handler = "0d"
ol-openedx-feedback = "0d"
ol-openedx-git-auto-export = "0d"
ol-openedx-logging = "0d"
ol-openedx-lti-utilities = "0d"
ol-openedx-otel-monitoring = "0d"
ol-openedx-rapid-response-reports = "0d"
ol-openedx-sentry = "0d"
ol-openedx-uai-content-customization = "0d"
ol-social-auth = "0d"
openedx-companion-auth = "0d"
rapid-response-xblock = "0d"
mitol-django-common = "0d"
mitol-django-mail = "0d"
mitol-django-authentication = "0d"
mitol-django-digitalcredentials = "0d"
mitol-django-geoip = "0d"
mitol-django-google-sheets = "0d"
mitol-django-google-sheets-deferrals = "0d"
mitol-django-google-sheets-refunds = "0d"
mitol-django-hubspot-api = "0d"
mitol-django-oauth-toolkit-extensions = "0d"
mitol-django-olposthog = "0d"
mitol-django-openedx = "0d"
mitol-django-payment-gateway = "0d"
mitol-django-transcoding = "0d"
mitol-django-apigateway = "0d"
mitol-django-observability = "0d"
mitol-django-scim = "0d"
mitol-drf-lint = "0d"

[tool.uv.sources]

Expand Down
51 changes: 51 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading