Skip to content
Open
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.150.7
---------------

- Fix consideration for program certificate generation (#3597)
- Update Learner Program Record Styling and remove header (#3593)

Version 1.150.6 (Released May 20, 2026)
---------------

Expand Down
15 changes: 11 additions & 4 deletions courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1115,18 +1115,25 @@ def _has_earned_program_cert(user, program):
bool: True if a user has earned all the course certificates required
for a given program else False
"""
program_course_ids = [course[0].id for course in program.courses]

user_courseruns = CourseRun.objects.filter(
enrollments__user=user,
enrollments__active=True,
enrollments__change_status__isnull=True,
).filter(course__in=program.courses_qset)

cert_courses = Course.objects.filter(
id__in=program_course_ids,
courseruns__in=user_courseruns,
courseruns__courseruncertificates__user=user,
courseruns__courseruncertificates__is_revoked=False,
)
grade_courses = Course.objects.filter(
id__in=program_course_ids,
courseruns__in=user_courseruns.exclude(
enrollment_modes__mode_slug=EDX_ENROLLMENT_VERIFIED_MODE
),
courseruns__grades__user=user,
courseruns__grades__passed=True,
).exclude(courseruns__enrollment_modes__mode_slug=EDX_ENROLLMENT_VERIFIED_MODE)
)
root = ProgramRequirement.get_root_nodes().get(program=program)

def _has_earned(node):
Expand Down
29 changes: 29 additions & 0 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1641,6 +1641,9 @@ def test_generate_program_certificate_success_single_requirement_course(
course_run = CourseRunFactory.create(course=course)
course_run.enrollment_modes.set(default_mode_records)
course_run.save()
CourseRunEnrollmentFactory.create(
run=course_run, user=user, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE
)
CourseRunGradeFactory.create(course_run=course_run, user=user, passed=True, grade=1)

CourseRunCertificateFactory.create(user=user, course_run=course_run)
Expand Down Expand Up @@ -1686,6 +1689,12 @@ def test_generate_program_certificate_success_multiple_required_courses(
run.enrollment_modes.set(default_mode_records)
run.save()

CourseRunEnrollmentFactory.create_batch(
3,
run=factory.Iterator(course_runs),
user=user,
enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE,
)
CourseRunCertificateFactory.create_batch(
3, user=user, course_run=factory.Iterator(course_runs)
)
Expand Down Expand Up @@ -1995,6 +2004,9 @@ def test_generate_program_certificate_with_subprogram_requirement( # noqa: PLR0
sub_course_run = CourseRunFactory.create(course=sub_course)
sub_course_run.enrollment_modes.set(default_mode_records)
sub_course_run.save()
CourseRunEnrollmentFactory.create(
run=sub_course_run, user=user, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE
)
CourseRunGradeFactory.create(
course_run=sub_course_run, user=user, passed=True, grade=1
)
Expand Down Expand Up @@ -2065,6 +2077,9 @@ def test_generate_program_certificate_with_revoked_subprogram_certificate(

# User completes the sub-program and gets a certificate, but it gets revoked
sub_course_run = CourseRunFactory.create(course=sub_course)
CourseRunEnrollmentFactory.create(
run=sub_course_run, user=user, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE
)
CourseRunGradeFactory.create(
course_run=sub_course_run, user=user, passed=True, grade=1
)
Expand Down Expand Up @@ -2122,6 +2137,14 @@ def test_generate_program_certificate_audit_courses(user, default_mode_records):
program.add_requirement(cert_course)
program.add_requirement(audit_course)

# Add some additional course runs for the audit course.
# This test missed a case - if the course had a mix of runs that were both
# audit-only and not, the certificates wouldn't be generated.

audit_verified_course_run = CourseRunFactory.create(course=audit_course)
audit_verified_course_run.enrollment_modes.set(default_mode_records)
audit_verified_course_run.save()

ProgramEnrollment.objects.create(
user=user,
program=program,
Expand Down Expand Up @@ -3478,6 +3501,9 @@ def test_generate_missing_program_certificates_creates_cert(
course_run = CourseRunFactory.create(course=course)
course_run.enrollment_modes.set(default_mode_records)
course_run.save()
CourseRunEnrollmentFactory.create(
run=course_run, user=user, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE
)

ProgramEnrollment.objects.create(
user=user, program=program, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE
Expand Down Expand Up @@ -3510,6 +3536,9 @@ def test_generate_missing_program_certificates_idempotent(
course_run = CourseRunFactory.create(course=course)
course_run.enrollment_modes.set(default_mode_records)
course_run.save()
CourseRunEnrollmentFactory.create(
run=course_run, user=user, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE
)

ProgramEnrollment.objects.create(
user=user, program=program, enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE
Expand Down
11 changes: 10 additions & 1 deletion courses/management/tests/manage_program_certificate_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,18 @@ def test_program_certificate_management_revoke_unrevoke_success(user, revoke, un
assert certificate.is_revoked is (False if unrevoke else True) # noqa: SIM211


@pytest.mark.parametrize(
"force_cert",
[
True,
False,
],
)
def test_program_certificate_management_create(
user,
program_with_empty_requirements, # noqa: F811
mocker,
force_cert,
):
"""
Test that create operation for program certificate management command
Expand Down Expand Up @@ -162,13 +170,14 @@ def test_program_certificate_management_create(
create=True,
program=program_with_empty_requirements.readable_id,
user=user.edx_username,
force=force_cert,
)

generated_certificates = ProgramCertificate.objects.filter(
user=user, program=program_with_empty_requirements
)

assert generated_certificates.count() == 1
assert generated_certificates.count() == (1 if force_cert else 0)


def test_program_certificate_management_force_create(
Expand Down
14 changes: 5 additions & 9 deletions frontend/public/scss/learner-records.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,13 @@
}

div.learner-record-inst-logo {
padding: 20px 40px;
border: 1px solid $home-page-border-grey;
border-radius: 5px;
align-self: start;
align-items: center;
margin: 0;
display: flex;
padding: 20px 32px 20px 32px;
}

img {
width: 200px;
height: auto;
.learner-record-inst-logo img {
height: 30.7569px;
opacity: 1;
}
}

Expand Down
18 changes: 17 additions & 1 deletion frontend/public/src/containers/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ export class App extends React.Component<Props, void> {
)
}

isLearnerRecordsPage() {
const { match, location } = this.props
return (
!!matchPath(location.pathname, {
path: urljoin(match.url, String(routes.learnerRecords)),
exact: false
}) ||
!!matchPath(location.pathname, {
path: urljoin(match.url, String(routes.sharedLearnerRecord)),
exact: false
})
)
}

render() {
const { match, currentUser, cartItemsCount, location } = this.props
if (!currentUser) {
Expand All @@ -104,7 +118,9 @@ export class App extends React.Component<Props, void> {

return (
<div className="app" aria-flowto="notifications-container">
{!this.isEcomServiceMode() && !this.isCheckoutRelatedPage() && (
{!this.isEcomServiceMode() &&
!this.isCheckoutRelatedPage() &&
!this.isLearnerRecordsPage() && (
<Header
currentUser={currentUser}
cartItemsCount={currentUser.is_authenticated ? cartItemsCount : 0}
Expand Down
34 changes: 34 additions & 0 deletions frontend/public/src/containers/App_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,40 @@ describe("Top-level App", () => {
assert.isFalse(inner.find("Header").exists())
})

it("does not render header on learner record page", async () => {
helper.handleRequestStub.returns(anonymousUser)
renderPage = helper.configureMountRenderer(
App,
InnerApp,
{},
{
match: { url: routes.root },
location: {
pathname: "/records/1/"
}
}
)
const { inner } = await renderPage()
assert.isFalse(inner.find("Header").exists())
})

it("does not render header on shared learner record page", async () => {
helper.handleRequestStub.returns(anonymousUser)
renderPage = helper.configureMountRenderer(
App,
InnerApp,
{},
{
match: { url: routes.root },
location: {
pathname: "/records/shared/test-uuid/"
}
}
)
const { inner } = await renderPage()
assert.isFalse(inner.find("Header").exists())
})

it("renders header on dashboard page", async () => {
helper.handleRequestStub.returns(anonymousUser)
renderPage = helper.configureMountRenderer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ export class LearnerRecordsPage extends React.Component<Props, State> {
<h3 className="learner-record-program-title">
{learnerRecord ?
learnerRecord.program.title :
"MITx Online Program Record"}
"MIT Learn Program Record"}
</h3>
<p>Program Record</p>
</div>
Expand Down Expand Up @@ -517,7 +517,7 @@ export class LearnerRecordsPage extends React.Component<Props, State> {
id="learner-record-school-name"
>
<div className="w-auto">
<div>MITx Online Program Record</div>
<div>MIT Learn Program Record</div>
<h1 className="learner-record-program-title">
{learnerRecord ? learnerRecord.program.title : null}
</h1>
Expand All @@ -537,8 +537,8 @@ export class LearnerRecordsPage extends React.Component<Props, State> {
</div>
<div className="learner-record-inst-logo">
<img
src="/static/images/mitx-online-logo.png"
alt="MITx Online Logo"
src="/static/images/mit-black-logo.png"
alt="MIT Logo"
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// @flow
import { assert } from "chai"
import { shallow } from "enzyme"

import LearnerRecordsPage, {
LearnerRecordsPage as InnerLearnerRecordsPage
} from "./LearnerRecordsPage"
import { makeLearnerRecord } from "../../../factories/course"
import IntegrationTestHelper from "../../../util/integration_test_helper"

describe("LearnerRecordsPage", () => {
let helper, renderPage

beforeEach(() => {
helper = new IntegrationTestHelper()
global.SETTINGS = {
site_name: "MITx Online",
support_email: "support@example.com"
}

renderPage = helper.configureShallowRenderer(
LearnerRecordsPage,
InnerLearnerRecordsPage,
{},
{
learnerRecord: null,
isSharedRecord: false,
history: {},
isLoading: false,
addUserNotification: helper.sandbox.stub(),
forceRequest: helper.sandbox.stub(),
enableRecordSharing: helper.sandbox.stub().resolves({}),
revokeRecordSharing: helper.sandbox.stub().resolves({}),
match: { params: { program: "1" } },
currentUser: { is_authenticated: true }
}
)
})

afterEach(() => {
helper.cleanup()
delete global.SETTINGS
})

it("keeps the records page title banner", async () => {
const { inner } = await renderPage()
const pageHeader = inner.find(".std-page-header").first()

assert.isTrue(pageHeader.exists())
assert.equal(pageHeader.find("h1").first().text(), "Program Record")
})

it("renders the MIT logo in the learner record header", async () => {
const learnerRecord = makeLearnerRecord(true)
const { inner } = await renderPage({}, { learnerRecord })
const learnerRecordTable = shallow(
inner.instance().renderLearnerRecordTable(learnerRecord)
)

const logo = learnerRecordTable
.find(".learner-record-inst-logo img")
.first()

assert.isTrue(logo.exists())
assert.equal(logo.prop("src"), "/static/images/mit-black-logo.png")
assert.equal(logo.prop("alt"), "MIT Logo")
})
})
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.150.6"
VERSION = "1.150.7"

log = logging.getLogger()

Expand Down