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
3 changes: 1 addition & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,4 @@ MITOL_APIGATEWAY_USERINFO_UPDATE=True

# Host for Digital Credentials issuer-coordinator service
VERIFIABLE_CREDENTIAL_SIGNER_URL=http://dcc.odl.local:4005/instance/test/credentials/issue
# By default, this is controlled by Posthog feature flag and is disabled for locals. To enable, set to True here.
ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING=False
VERIFIABLE_CREDENTIAL_BEARER_TOKEN='test'
9 changes: 9 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Release Notes
=============

Version 1.142.2
---------------

- Restore product type information in OpenAPI Spec (#3396)
- Add program type to the receipt email template (#3395)
- Add edX entitlement import to migrate_edx_data management command (#3383)
- Add auth to cart page (#3392)
- Add criteria field and course-level feature flag to CMS CertificatePage (#3373)

Version 1.142.1 (Released March 17, 2026)
---------------

Expand Down
4 changes: 0 additions & 4 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -783,10 +783,6 @@
"VERIFIABLE_CREDENTIAL_DID":{
"description": "The Decentralized Identifier (DID) used as the issuer for verifiable credentials.",
"required": false
},
"ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING":{
"description": "Whether to enable verifiable credentials provisioning functionality if Posthog feature flag is disabled.",
"required": false
}
},
"keywords": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 5.1.15 on 2026-03-12 17:01

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("cms", "0056_alter_coursepage_max_price_and_more"),
]

operations = [
migrations.AddField(
model_name="certificatepage",
name="should_provision_verifiable_credential",
field=models.BooleanField(
default=False,
help_text="Whether a verifiable credential should be provisioned for this certificate.",
),
),
migrations.AddField(
model_name="certificatepage",
name="verifiable_credential_criteria",
field=models.CharField(
blank=True,
help_text="For verifiable credentials issued for this certificate, this is the criteria narrative field. It should be something descriptive, like a list of completed courses, and may be plaintext or markdown. If it is not supplied, no verifiable credential will be provisioned for those certificates.",
max_length=250,
null=True,
),
),
]
16 changes: 15 additions & 1 deletion cms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.forms import ChoiceField, IntegerField
from django.forms import ChoiceField, IntegerField, Textarea
from django.http import Http404
from django.template.response import TemplateResponse
from django.urls import reverse
Expand Down Expand Up @@ -373,11 +373,25 @@ class CertificatePage(CourseProgramChildPage):
use_json_field=True,
)

verifiable_credential_criteria = models.CharField( # noqa: DJ001
max_length=250,
null=True,
blank=True,
help_text="For verifiable credentials issued for this certificate, this is the criteria narrative field. It should be something descriptive, like a list of completed courses, and may be plaintext or markdown. If it is not supplied, no verifiable credential will be provisioned for those certificates.",
)

should_provision_verifiable_credential = models.BooleanField(
default=False,
help_text="Whether a verifiable credential should be provisioned for this certificate.",
)

content_panels = [
FieldPanel("product_name"),
FieldPanel("CEUs"),
FieldPanel("overrides"),
FieldPanel("signatories"),
FieldPanel("verifiable_credential_criteria", widget=Textarea),
FieldPanel("should_provision_verifiable_credential"),
]
api_fields = [
APIField("product_name"),
Expand Down
28 changes: 28 additions & 0 deletions config/apisix/apisix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,33 @@ routes:
- "/login/"
- "/admin/login*"

- id: 3
name: "app-cart"
desc: "Require login for cart so session is established."
priority: 5
upstream_id: 1
plugins:
openid-connect:
client_id: ${{KEYCLOAK_CLIENT_ID}}
client_secret: ${{KEYCLOAK_CLIENT_SECRET}}
discovery: ${{KEYCLOAK_DISCOVERY_URL}}
realm: ${{KEYCLOAK_REALM}}
scope: "openid profile ol-profile"
bearer_only: false
introspection_endpoint_auth_method: "client_secret_post"
ssl_verify: false
session:
secret: ${{APISIX_SESSION_SECRET_KEY}}
logout_path: "/logout/oidc"
post_logout_redirect_uri: ${{APP_LOGOUT_URL}}
unauth_action: "auth"
response-rewrite:
headers:
set:
Content-Security-Policy: frame-ancestors 'self' ${{OPENEDX_API_BASE_URL}}
uris:
- "/cart"
- "/cart/"


#END
65 changes: 40 additions & 25 deletions courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

import requests
import reversion
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
Expand All @@ -25,7 +24,6 @@
first_or_none,
has_equal_properties,
)
from mitol.olposthog.features import is_enabled
from opaque_keys.edx.keys import CourseKey
from requests.exceptions import ConnectionError as RequestsConnectionError
from requests.exceptions import HTTPError
Expand All @@ -41,7 +39,6 @@
PROGRAM_TEXT_ID_PREFIX,
)
from courses.models import (
BaseCertificate,
BlockedCountry,
Course,
CourseRun,
Expand Down Expand Up @@ -93,6 +90,8 @@
from django.db.models.query import QuerySet
from edx_api.course_detail.models import CourseMode

from cms.models import CertificatePage


log = logging.getLogger(__name__)
UserEnrollments = namedtuple( # noqa: PYI024
Expand Down Expand Up @@ -1423,7 +1422,10 @@ def import_courserun_from_edx( # noqa: C901, PLR0913
}


def get_verifiable_credentials_payload(certificate: BaseCertificate) -> dict:
def get_verifiable_credentials_payload(
certificate: CourseRunCertificate | ProgramCertificate,
certificate_page: CertificatePage,
) -> dict:
# TODO: We could optimize these queries #noqa: TD002, TD003, FIX002
# It's not a massive priority though, as we have a total of 20k certs in prod as of 12/25
learn_hostname = ENV_TO_LEARN_HOSTNAME_MAP.get(
Expand All @@ -1435,14 +1437,6 @@ def get_verifiable_credentials_payload(certificate: BaseCertificate) -> dict:
course_run = certificate.course_run
course = course_run.course
course_page = course.page
if not course_page.what_you_learn:
# If it's empty, we can't generate a valid payload as narrative is required.
log.error(
"Error creating verifiable credential - missing 'what_you_learn' for course page %s for certificate %s",
course_page.title,
certificate,
)
raise InvalidCertificateError

course_url_id = course.readable_id
url = f"https://{learn_hostname}/courses/{course_url_id}"
Expand All @@ -1453,10 +1447,7 @@ def get_verifiable_credentials_payload(certificate: BaseCertificate) -> dict:
achievement_image_url = (
get_thumbnail_url(course_page) if course_page.feature_image else ""
)
soup = BeautifulSoup(course_page.what_you_learn, "html.parser")
narrative = "\n".join(
[f"- {stripped_string}" for stripped_string in soup.stripped_strings]
)
narrative = certificate_page.verifiable_credential_criteria

elif isinstance(certificate, ProgramCertificate):
cert_type = "program"
Expand All @@ -1470,9 +1461,7 @@ def get_verifiable_credentials_payload(certificate: BaseCertificate) -> dict:
achievement_image_url = (
get_thumbnail_url(program_page) if program_page.feature_image else ""
)
narrative = "\n".join(
[f"- {program_course[0].title}" for program_course in program.courses]
)
narrative = certificate_page.verifiable_credential_criteria
else:
raise InvalidCertificateError

Expand Down Expand Up @@ -1558,25 +1547,51 @@ def request_verifiable_credential(payload) -> dict:
return resp.json()


def should_provision_verifiable_credential() -> bool:
def should_provision_verifiable_credential(
certificate_page: CertificatePage | None,
) -> bool:
if not certificate_page:
return False

return (
is_enabled(features.ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING, False) # noqa: FBT003
or settings.ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING
certificate_page.should_provision_verifiable_credential
and certificate_page.verifiable_credential_criteria
)


def create_verifiable_credential(certificate: BaseCertificate, *, raise_on_error=False):
def get_certificate_page(
certificate: CourseRunCertificate | ProgramCertificate,
) -> CertificatePage | None:
from cms.models import CertificatePage # noqa: PLC0415

certificate_page = None
if certificate.certificate_page_revision:
certificate_page = CertificatePage.objects.filter(
pk=int(certificate.certificate_page_revision.object_id),
).first()
return certificate_page


def create_verifiable_credential(
certificate: ProgramCertificate | CourseRunCertificate, *, raise_on_error=False
):
"""
Create a verifiable credential for the given course run certificate.

Args:
certificate (CourseRunCertificate): The course run certificate for which to create the verifiable credential.
raise_on_error (bool): If True, will re-raise any exceptions encountered during VC creation.
"""

try:
if not should_provision_verifiable_credential():
# We always look at the most recent certificate page revision for content and whether or not to provision
# You can imagine that if we used the linked revision, if the certificate page was in a bad state
# when the certificate was issued, we could never backfill the VC even if we fixed it later.
certificate_page = get_certificate_page(certificate)

if not should_provision_verifiable_credential(certificate_page):
return
payload = get_verifiable_credentials_payload(certificate)
payload = get_verifiable_credentials_payload(certificate, certificate_page)

# Call the signing service to create the new credential
credential = request_verifiable_credential(payload)
Expand Down
36 changes: 21 additions & 15 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2377,9 +2377,10 @@ def test_course_run_certificate_verifiable_credentials(
"courses.api.request_verifiable_credential",
side_effect=return_signed_credential,
)
mocker.patch(
"courses.api.should_provision_verifiable_credential", return_value=True
)
mock_certificate_page = Mock()
mock_certificate_page.verifiable_credential_criteria = "mock_credential_data"
mock_certificate_page.should_provision_verifiable_credential = True
mocker.patch("courses.api.get_certificate_page", return_value=mock_certificate_page)
passed_grade_with_enrollment.course_run.course.page.what_you_learn = (
"Some learning content"
)
Expand Down Expand Up @@ -2412,9 +2413,11 @@ def test_program_certificate_verifiable_credentials(
"courses.api.request_verifiable_credential",
side_effect=return_signed_credential,
)
mocker.patch(
"courses.api.should_provision_verifiable_credential", return_value=True
)

mock_certificate_page = Mock()
mock_certificate_page.verifiable_credential_criteria = "mock_credential_data"
mock_certificate_page.should_provision_verifiable_credential = True
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))
CourseRunCertificateFactory.create_batch(
Expand Down Expand Up @@ -2513,7 +2516,9 @@ def test_course_run_certificate_verifiable_credentials_signing_payload(
)
course_run_cert.course_run.course.page.save()

payload = get_verifiable_credentials_payload(course_run_cert)
mock_certificate_page = Mock()
mock_certificate_page.verifiable_credential_criteria = "mock_credential_data"
payload = get_verifiable_credentials_payload(course_run_cert, mock_certificate_page)

# Assert the expected payload structure
expected_payload = {
Expand Down Expand Up @@ -2552,7 +2557,9 @@ def test_course_run_certificate_verifiable_credentials_signing_payload(
"id": "https://learn.mit.edu/courses/course-v1:MITx+6.00.1x",
"achievementType": "Course",
"type": ["Achievement"],
"criteria": {"narrative": "- Learn Python programming fundamentals"},
"criteria": {
"narrative": mock_certificate_page.verifiable_credential_criteria
},
"description": "John Doe has successfully completed all modules and earned a Course Certificate in Introduction to Python.",
"name": "Introduction to Python",
"image": {
Expand Down Expand Up @@ -2619,12 +2626,9 @@ def test_program_certificate_verifiable_credentials_signing_payload(
program_cert.program.add_requirement(course2)
program_cert.program.add_requirement(course3)

payload = get_verifiable_credentials_payload(program_cert)

# Build expected narrative from the actual course titles
narrative = "\n".join(
[f"- {course[0].title}" for course in program_cert.program.courses]
)
mock_certificate_page = Mock()
mock_certificate_page.verifiable_credential_criteria = "mock_credential_data"
payload = get_verifiable_credentials_payload(program_cert, mock_certificate_page)

# Assert the expected payload structure
expected_payload = {
Expand Down Expand Up @@ -2663,7 +2667,9 @@ def test_program_certificate_verifiable_credentials_signing_payload(
"id": "https://learn.mit.edu/programs/program-v1:MITx+DataScienceMM",
"achievementType": "Program",
"type": ["Achievement"],
"criteria": {"narrative": narrative},
"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",
"image": {
Expand Down
Loading
Loading