diff --git a/RELEASE.rst b/RELEASE.rst index 18594f6706..c6ef487394 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 0.41.2 +-------------- + +- Certificate page UI (#2429) +- Fix bad urls for edx video contentfiles (#2444) +- add program enrollments api and view program certificate button (#2439) + Version 0.41.1 (Released August 18, 2025) -------------- diff --git a/data_fixtures/migrations/0017_update_edx_content_urls.py b/data_fixtures/migrations/0017_update_edx_content_urls.py new file mode 100644 index 0000000000..8c68c05f86 --- /dev/null +++ b/data_fixtures/migrations/0017_update_edx_content_urls.py @@ -0,0 +1,44 @@ +# Generated manually + + +from django.db import migrations +from django.db.models import Value +from django.db.models.functions import Replace + +from learning_resources.utils import content_files_loaded_actions + + +def update_contentfile_urls(apps, schema_editor): + """ + Update incorrect ContentFile URLs that use the "/jump_to/" format + """ + + ContentFile = apps.get_model("learning_resources", "ContentFile") + LearningResourceRun = apps.get_model("learning_resources", "LearningResourceRun") + content_files = ContentFile.objects.filter(url__contains="/jump_to/").only( + "id", "url", "run__id" + ) + # Get unique run IDs before updating + run_ids = set(content_files.values_list("run__id", flat=True).distinct()) + + # Update all ContentFile URLs in a single database query + content_files.update(url=Replace("url", Value("/jump_to/"), Value("/jump_to_id/"))) + + # Process the runs + for run in LearningResourceRun.objects.filter(id__in=run_ids): + content_files_loaded_actions(run) + + +class Migration(migrations.Migration): + dependencies = [ + ( + "data_fixtures", + "0016_add_canvas_platform", + ), + ] + + operations = [ + migrations.RunPython( + update_contentfile_urls, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/frontends/api/package.json b/frontends/api/package.json index 9a85853dbc..e7763145fa 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -30,7 +30,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "2025.8.12", + "@mitodl/mitxonline-api-axios": "2025.8.18", "@tanstack/react-query": "^5.66.0", "axios": "^1.6.3" } diff --git a/frontends/api/src/mitxonline/clients.ts b/frontends/api/src/mitxonline/clients.ts index 073ff1d62e..3227a2ee7c 100644 --- a/frontends/api/src/mitxonline/clients.ts +++ b/frontends/api/src/mitxonline/clients.ts @@ -7,6 +7,7 @@ import { ProgramsApi, ProgramCertificatesApi, UsersApi, + ProgramEnrollmentsApi, } from "@mitodl/mitxonline-api-axios/v2" import axios from "axios" @@ -51,10 +52,17 @@ const courseRunEnrollmentsApi = new EnrollmentsApi( axiosInstance, ) +const programEnrollmentsApi = new ProgramEnrollmentsApi( + undefined, + BASE_PATH, + axiosInstance, +) + export { usersApi, b2bApi, courseRunEnrollmentsApi, + programEnrollmentsApi, programsApi, programCollectionsApi, coursesApi, diff --git a/frontends/api/src/mitxonline/hooks/enrollment/queries.ts b/frontends/api/src/mitxonline/hooks/enrollment/queries.ts index 1d790f3dbf..8cad29588b 100644 --- a/frontends/api/src/mitxonline/hooks/enrollment/queries.ts +++ b/frontends/api/src/mitxonline/hooks/enrollment/queries.ts @@ -1,7 +1,11 @@ import { queryOptions } from "@tanstack/react-query" -import type { CourseRunEnrollment } from "@mitodl/mitxonline-api-axios/v2" +import type { + CourseRunEnrollment, + UserProgramEnrollmentDetail, +} from "@mitodl/mitxonline-api-axios/v2" -import { courseRunEnrollmentsApi } from "../../clients" +import { courseRunEnrollmentsApi, programEnrollmentsApi } from "../../clients" +import { RawAxiosRequestConfig } from "axios" const enrollmentKeys = { root: ["mitxonline", "enrollments"], @@ -10,10 +14,11 @@ const enrollmentKeys = { "courseRunEnrollments", "list", ], - programEnrollmentsList: () => [ + programEnrollmentsList: (opts?: RawAxiosRequestConfig) => [ ...enrollmentKeys.root, "programEnrollments", "list", + opts, ], } @@ -25,6 +30,15 @@ const enrollmentQueries = { return courseRunEnrollmentsApi.enrollmentsList().then((res) => res.data) }, }), + programEnrollmentsList: (opts?: RawAxiosRequestConfig) => + queryOptions({ + queryKey: enrollmentKeys.programEnrollmentsList(opts), + queryFn: async (): Promise => { + return programEnrollmentsApi + .programEnrollmentsList(opts) + .then((res) => res.data) + }, + }), } export { enrollmentQueries, enrollmentKeys } diff --git a/frontends/api/src/mitxonline/test-utils/factories/courses.ts b/frontends/api/src/mitxonline/test-utils/factories/courses.ts index adc81659c8..871f1165cc 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/courses.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/courses.ts @@ -1,12 +1,80 @@ import { mergeOverrides, makePaginatedFactory } from "ol-test-utilities" import type { PartialFactory } from "ol-test-utilities" -import type { CourseWithCourseRunsSerializerV2 } from "@mitodl/mitxonline-api-axios/v2" +import type { + CourseWithCourseRunsSerializerV2, + V1CourseWithCourseRuns, +} from "@mitodl/mitxonline-api-axios/v2" import { faker } from "@faker-js/faker/locale/en" import { UniqueEnforcer } from "enforce-unique" const uniqueCourseId = new UniqueEnforcer() const uniqueCourseRunId = new UniqueEnforcer() +const v1Course: PartialFactory = (overrides = {}) => { + const defaults: V1CourseWithCourseRuns = { + id: uniqueCourseId.enforce(() => faker.number.int()), + title: faker.lorem.words(3), + readable_id: faker.lorem.slug(), + next_run_id: faker.number.int(), + departments: [ + { + name: faker.company.name(), + }, + ], + page: { + feature_image_src: faker.image.url(), + page_url: faker.internet.url(), + description: faker.lorem.paragraph(), + live: faker.datatype.boolean(), + length: `${faker.number.int({ min: 1, max: 12 })} weeks`, + effort: `${faker.number.int({ min: 1, max: 10 })} hours/week`, + financial_assistance_form_url: faker.internet.url(), + current_price: faker.number.int({ min: 0, max: 1000 }), + instructors: [ + { + name: faker.person.fullName(), + bio: faker.lorem.paragraph(), + }, + ], + }, + programs: null, + courseruns: [ + { + id: uniqueCourseRunId.enforce(() => faker.number.int()), + title: faker.lorem.words(3), + start_date: faker.date.future().toISOString(), + end_date: faker.date.future().toISOString(), + enrollment_start: faker.date.past().toISOString(), + enrollment_end: faker.date.future().toISOString(), + expiration_date: faker.date.future().toISOString(), + courseware_url: faker.internet.url(), + courseware_id: faker.string.uuid(), + certificate_available_date: faker.date.future().toISOString(), + upgrade_deadline: faker.date.future().toISOString(), + is_upgradable: faker.datatype.boolean(), + is_enrollable: faker.datatype.boolean(), + is_archived: faker.datatype.boolean(), + is_self_paced: faker.datatype.boolean(), + run_tag: faker.lorem.word(), + live: faker.datatype.boolean(), + course_number: faker.lorem.word(), + products: [ + { + id: faker.number.int(), + price: faker.commerce.price(), + description: faker.lorem.sentence(), + is_active: faker.datatype.boolean(), + product_flexible_price: null, + }, + ], + approved_flexible_price_exists: faker.datatype.boolean(), + }, + ], + } + + return mergeOverrides(defaults, overrides) +} + const course: PartialFactory = ( overrides = {}, ) => { @@ -91,6 +159,7 @@ const course: PartialFactory = ( return mergeOverrides(defaults, overrides) } +const v1Courses = makePaginatedFactory(v1Course) const courses = makePaginatedFactory(course) -export { course, courses } +export { v1Course, v1Courses, course, courses } diff --git a/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts b/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts index a419a31326..99b57908e5 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts @@ -4,8 +4,10 @@ import type { PartialFactory } from "ol-test-utilities" import type { CourseRunEnrollment, CourseRunGrade, + UserProgramEnrollmentDetail, } from "@mitodl/mitxonline-api-axios/v2" import { UniqueEnforcer } from "enforce-unique" +import { factories } from ".." const uniqueEnrollmentId = new UniqueEnforcer() const uniqueRunId = new UniqueEnforcer() @@ -99,9 +101,56 @@ const courseEnrollment: PartialFactory = ( return mergeOverrides(defaults, overrides) } +const programEnrollment: PartialFactory = ( + overrides = {}, +): UserProgramEnrollmentDetail => { + const defaults: UserProgramEnrollmentDetail = { + certificate: faker.datatype.boolean() + ? { + uuid: faker.string.uuid(), + link: faker.internet.url(), + } + : null, + program: { + id: faker.number.int(), + title: faker.lorem.words(3), + readable_id: faker.lorem.slug(), + courses: factories.courses.v1Course(), + requirements: { + required: [faker.number.int()], + electives: [faker.number.int()], + }, + req_tree: [], + page: { + feature_image_src: faker.image.url(), + page_url: faker.internet.url(), + financial_assistance_form_url: faker.internet.url(), + description: faker.lorem.paragraph(), + live: faker.datatype.boolean(), + length: `${faker.number.int({ min: 1, max: 12 })} weeks`, + effort: `${faker.number.int({ min: 1, max: 10 })} hours/week`, + price: faker.commerce.price(), + }, + program_type: faker.helpers.arrayElement([ + "certificate", + "degree", + "diploma", + ]), + departments: [ + { + name: faker.company.name(), + }, + ], + live: faker.datatype.boolean(), + }, + enrollments: [courseEnrollment()], + } + return mergeOverrides(defaults, overrides) +} + // Not paginated const courseEnrollments = (count: number): CourseRunEnrollment[] => { return new Array(count).fill(null).map(() => courseEnrollment()) } -export { courseEnrollment, courseEnrollments, grade } +export { courseEnrollment, courseEnrollments, grade, programEnrollment } diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index 129e030080..6ebaaf6b74 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -19,6 +19,10 @@ const enrollment = { `${API_BASE_URL}/api/v1/enrollments/${id ? `${id}/` : ""}`, } +const programEnrollments = { + enrollmentsList: () => `${API_BASE_URL}/api/v1/program_enrollments/`, +} + const b2b = { courseEnrollment: (readableId?: string) => `${API_BASE_URL}/api/v0/b2b/enroll/${readableId}/`, @@ -59,4 +63,5 @@ export { programCollections, courses, organization, + programEnrollments, } diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 9ee6c74782..f2f968dbb7 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -92,16 +92,7 @@ const nextConfig = { images: { remotePatterns: [ { - protocol: "http", hostname: "**", - port: "", - pathname: "**", - }, - { - protocol: "https", - hostname: "**", - port: "", - pathname: "**", }, ], }, diff --git a/frontends/main/public/images/certificate-badge-desktop.svg b/frontends/main/public/images/certificate-badge-desktop.svg new file mode 100644 index 0000000000..d94accad15 --- /dev/null +++ b/frontends/main/public/images/certificate-badge-desktop.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontends/main/public/images/certificate-badge-mobile.svg b/frontends/main/public/images/certificate-badge-mobile.svg new file mode 100644 index 0000000000..e2e09beda8 --- /dev/null +++ b/frontends/main/public/images/certificate-badge-mobile.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontends/main/public/images/mit-learn-logo-black.svg b/frontends/main/public/images/mit-learn-logo-black.svg new file mode 100644 index 0000000000..7fa921fc6c --- /dev/null +++ b/frontends/main/public/images/mit-learn-logo-black.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontends/main/public/images/mit-open-learning-logo.svg b/frontends/main/public/images/mit-open-learning-logo.svg new file mode 100644 index 0000000000..9f54342c30 --- /dev/null +++ b/frontends/main/public/images/mit-open-learning-logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx new file mode 100644 index 0000000000..2cc5b029ba --- /dev/null +++ b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx @@ -0,0 +1,502 @@ +"use client" + +import React from "react" +import { useParams } from "next/navigation" +import Image from "next/image" +import { Link, Typography, styled } from "ol-components" +import backgroundImage from "@/public/images/backgrounds/error_page_background.svg" +import { certificateQueries } from "api/mitxonline-hooks/certificates" +import { useQuery } from "@tanstack/react-query" +import OpenLearningLogo from "@/public/images/mit-open-learning-logo.svg" +import CertificateBadgeDesktop from "@/public/images/certificate-badge-desktop.svg" +import CertificateBadgeMobile from "@/public/images/certificate-badge-mobile.svg" +import { formatDate, NoSSR } from "ol-utilities" + +const Page = styled.div(({ theme }) => ({ + backgroundImage: `url(${backgroundImage.src})`, + backgroundAttachment: "fixed", + backgroundRepeat: "no-repeat", + backgroundSize: "contain", + display: "flow-root", + flexGrow: 1, + height: "100%", + padding: "0 16px 90px", + [theme.breakpoints.down("sm")]: { + backgroundImage: "none", + padding: "0 40px 40px", + }, +})) + +const Title = styled(Typography)(({ theme }) => ({ + margin: "60px 160px 40px", + textAlign: "center", + span: { + fontWeight: theme.typography.fontWeightLight, + color: theme.custom.colors.darkGray2, + }, + [theme.breakpoints.down("lg")]: { + span: { + fontSize: theme.typography.pxToRem(26), + lineHeight: theme.typography.pxToRem(30), + }, + }, + [theme.breakpoints.down("md")]: { + textAlign: "left", + margin: "24px 0", + span: { + fontSize: theme.typography.pxToRem(24), + lineHeight: theme.typography.pxToRem(30), + }, + }, +})) + +const Certificate = styled.div(({ theme }) => ({ + maxWidth: "1306px", + minWidth: "1200px", + border: `4px solid ${theme.custom.colors.silverGray}`, + padding: "24px 23px", + backgroundColor: theme.custom.colors.white, + marginTop: "50px", + margin: "0 auto", + [theme.breakpoints.down("lg")]: { + padding: 0, + border: "none", + maxWidth: "unset", + minWidth: "unset", + }, +})) + +const Inner = styled.div(({ theme }) => ({ + border: `1px solid ${theme.custom.colors.silverGrayLight}`, + padding: "67px", + display: "flex", + flexDirection: "column", + gap: "56px", + position: "relative", + [theme.breakpoints.down("lg")]: { + padding: "40px", + gap: "40px", + }, + [theme.breakpoints.down("md")]: { + border: `2px solid ${theme.custom.colors.lightGray2}`, + padding: "24px 16px", + gap: "24px", + textAlign: "center", + }, +})) + +const Logo = styled(Image)(({ theme }) => ({ + width: "260px", + height: "auto", + [theme.breakpoints.down("lg")]: { + width: "230px", + height: "59px", + }, + [theme.breakpoints.down("md")]: { + width: "129px", + margin: "0 auto", + }, +})) + +const Badge = styled.div(({ theme }) => ({ + backgroundImage: `url(${CertificateBadgeDesktop.src})`, + position: "absolute", + top: 0, + right: "67px", + width: "230px", + height: "391px", + textAlign: "center", + padding: "81px 34px", + backgroundRepeat: "no-repeat", + [theme.breakpoints.down("lg")]: { + backgroundImage: `url(${CertificateBadgeMobile.src})`, + top: "24px", + right: "40px", + width: "156px", + height: "191px", + }, + [theme.breakpoints.down("md")]: { + position: "relative", + height: "191px", + top: 0, + right: 0, + margin: "0 auto", + }, +})) + +const BadgeText = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.white, + position: "absolute", + top: "169px", + right: "26px", + width: "175px", + textAlign: "center", + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(16), + lineHeight: "150%", + fontWeight: theme.typography.fontWeightMedium, + top: "53px", + right: "18px", + width: "119px", + }, + [theme.breakpoints.down("md")]: { + width: "130px", + position: "absolute", + top: "50px", + right: "50%", + transform: "translateX(50%)", + }, +})) + +const Certification = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "16px", + maxWidth: "850px", + ".MuiTypography-h4": { + fontWeight: theme.typography.fontWeightLight, + color: theme.custom.colors.silverGrayDark, + }, + [theme.breakpoints.down("lg")]: { + ".MuiTypography-h4": { + fontSize: theme.typography.pxToRem(16), + }, + }, + [theme.breakpoints.down("md")]: { + gap: 0, + ".MuiTypography-h4": { + fontSize: theme.typography.pxToRem(16), + fontWeight: theme.typography.fontWeightMedium, + lineHeight: "150%", + color: theme.custom.colors.silverGrayDark, + }, + }, +})) + +const NameText = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.red, + display: "block", + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(34), + lineHeight: theme.typography.pxToRem(40), + }, + [theme.breakpoints.down("md")]: { + fontSize: theme.typography.pxToRem(24), + lineHeight: theme.typography.pxToRem(30), + marginTop: "4px", + }, +})) + +const AchievementText = styled(Typography)(({ theme }) => ({ + fontWeight: theme.typography.fontWeightLight, + fontSize: theme.typography.pxToRem(20), + lineHeight: theme.typography.pxToRem(26), + color: theme.custom.colors.silverGrayDark, + strong: { + fontWeight: theme.typography.fontWeightBold, + }, + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(16), + lineHeight: theme.typography.pxToRem(24), + }, + [theme.breakpoints.down("md")]: { + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(18), + marginTop: "16px", + }, +})) + +const CourseInfo = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "16px", + ".MuiTypography-h4": { + fontWeight: theme.typography.fontWeightLight, + color: theme.custom.colors.silverGrayDark, + }, + [theme.breakpoints.down("lg")]: { + ".MuiTypography-h2": { + fontSize: theme.typography.pxToRem(28), + lineHeight: theme.typography.pxToRem(36), + }, + ".MuiTypography-h4": { + fontSize: theme.typography.pxToRem(16), + lineHeight: theme.typography.pxToRem(20), + }, + }, + [theme.breakpoints.down("md")]: { + ".MuiTypography-h2": { + fontSize: theme.typography.pxToRem(18), + lineHeight: theme.typography.pxToRem(26), + fontWeight: theme.typography.fontWeightMedium, + }, + ".MuiTypography-h4": { + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(18), + color: theme.custom.colors.darkGray1, + }, + }, +})) + +const Spacer = styled.div(({ theme }) => ({ + height: "30px", + [theme.breakpoints.down("lg")]: { + display: "none", + }, +})) + +const Signatories = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "row", + gap: "16px", + width: "100%", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + gap: "24px", + }, +})) + +const Signatory = styled.div(({ theme }) => ({ + flex: "1 1 0", + minWidth: 0, + span: { + display: "block", + }, + ".MuiTypography-body1": { + color: theme.custom.colors.silverGrayDark, + }, + [theme.breakpoints.down("lg")]: { + ".MuiTypography-body1": { + fontSize: theme.typography.pxToRem(12), + lineHeight: theme.typography.pxToRem(16), + }, + ".MuiTypography-body1:last-child": { + marginTop: "8px", + }, + }, + [theme.breakpoints.down("md")]: { + ".MuiTypography-body1": { + color: theme.custom.colors.darkGray1, + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(18), + }, + borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, + paddingBottom: "24px", + p: { + marginTop: "8px", + }, + }, +})) + +const Signature = styled.img(({ theme }) => ({ + width: "auto", + height: "60px", + [theme.breakpoints.down("lg")]: { + height: "54px", + }, + [theme.breakpoints.down("md")]: { + height: "40px", + }, +})) + +const SignatoryName = styled(Typography)(({ theme }) => ({ + marginBottom: "8px", + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(18), + lineHeight: theme.typography.pxToRem(26), + fontWeight: theme.typography.fontWeightMedium, + }, + [theme.breakpoints.down("md")]: { + fontSize: theme.typography.pxToRem(16), + lineHeight: "150%", + marginTop: "16px", + }, +})) + +const CertificateId = styled(Typography)(({ theme }) => ({ + span: { + color: theme.custom.colors.silverGrayDark, + }, + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(12), + lineHeight: theme.typography.pxToRem(16), + }, + [theme.breakpoints.down("md")]: { + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(20), + span: { + display: "block", + }, + }, +})) + +const Note = styled(Typography)(({ theme }) => ({ + margin: "48px 0 64px 0", + textAlign: "center", + fontSize: theme.typography.pxToRem(16), + a: { + color: theme.custom.colors.red, + textDecoration: "underline", + fontSize: theme.typography.pxToRem(16), + }, + [theme.breakpoints.down("lg")]: { + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(18), + a: { + fontSize: theme.typography.pxToRem(14), + }, + }, + [theme.breakpoints.down("md")]: { + margin: "32px 0 16px", + textAlign: "left", + }, +})) + +enum CertificateType { + Course = "course", + Program = "program", +} + +const CertificatePage: React.FC = () => { + const { certificateType, uuid } = useParams<{ + certificateType: CertificateType + uuid: string + }>() + + console.log("certificateType", certificateType) + console.log("uuid", uuid) + + const { data: courseCertData, isLoading: isCourseLoading } = useQuery({ + ...certificateQueries.courseCertificatesRetrieve({ + cert_uuid: uuid, + }), + enabled: certificateType === CertificateType.Course, + }) + + const { data: programCertData, isLoading: isProgramLoading } = useQuery({ + ...certificateQueries.programCertificatesRetrieve({ + cert_uuid: uuid, + }), + enabled: certificateType === CertificateType.Program, + }) + + const isLoading = isCourseLoading || isProgramLoading + + if (isLoading) { + return + } + + const title = + certificateType === CertificateType.Course + ? courseCertData?.course_run?.course?.title + : programCertData?.program?.title + + const displayType = + certificateType === CertificateType.Course + ? "Module Certificate" + : `${programCertData?.program?.program_type} Certificate` + + const userName = + certificateType === CertificateType.Course + ? courseCertData?.user?.name + : programCertData?.user?.name + + const shortDisplayType = + certificateType === CertificateType.Course + ? "module" + : programCertData?.program?.program_type === "Series" + ? "series" + : `${programCertData?.program?.program_type} program` + + const ceus = + certificateType === CertificateType.Course + ? null + : programCertData?.certificate_page?.CEUs + + const signatories = + certificateType === CertificateType.Course + ? courseCertData?.certificate_page?.signatory_items + : programCertData?.certificate_page?.signatory_items + + const startDate = + certificateType === CertificateType.Course + ? courseCertData?.course_run?.start_date + : programCertData?.program?.start_date + + const endDate = + certificateType === CertificateType.Course + ? courseCertData?.course_run?.end_date + : programCertData?.program?.end_date + + return ( + + + <Typography variant="h3"> + <strong>{title}</strong> {displayType} + </Typography> + + + + + + {displayType} + + + This is to certify that + {userName} + + has successfully completed all requirements of the{" "} + Universal Artificial Intelligence{" "} + {shortDisplayType}: + + + + {title} + {ceus ? ( + + Awarded {ceus} Continuing Education Units (CEUs) + + ) : null} + {startDate && endDate && ( + + + {formatDate(startDate)} - {formatDate(endDate)} + + + )} + {ceus ? null : } + + + {signatories?.map((signatory, index) => ( + + + {signatory.name} + {signatory.title_1} + {signatory.title_2} + + {signatory.organization} + + + ))} + + + Valid Certificate ID: {uuid} + + + + + Note: The name displayed on your certificate is based + on your{" "} + + Profile + {" "} + information. + + + ) +} + +export default CertificatePage diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index e697569c04..0bbf1ec12f 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -338,6 +338,7 @@ const DashboardCard: React.FC = ({ }) => { const course = dashboardResource as DashboardCourse const { title, marketingUrl, enrollment, run } = course + const titleSection = isLoading ? ( <> @@ -353,8 +354,9 @@ const DashboardCard: React.FC = ({ > {title} - {enrollment?.status === EnrollmentStatus.Completed ? ( - + {enrollment?.status === EnrollmentStatus.Completed && + run.certificate?.link ? ( + {} View Certificate diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts index 81e5eb815c..72d23d8139 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts @@ -14,7 +14,7 @@ import moment from "moment" const makeCourses = factories.courses.courses const makeProgram = factories.programs.program const makeProgramCollection = factories.programs.programCollection -const makeEnrollment = factories.enrollment.courseEnrollment +const makeCourseEnrollment = factories.enrollment.courseEnrollment const makeGrade = factories.enrollment.grade const dashboardCourse: PartialFactory = (...overrides) => { @@ -45,20 +45,20 @@ const dashboardCourse: PartialFactory = (...overrides) => { const setupEnrollments = (includeExpired: boolean) => { const completed = [ - makeEnrollment({ + makeCourseEnrollment({ run: { title: "C Course Ended" }, grades: [makeGrade({ passed: true })], }), ] const expired = includeExpired ? [ - makeEnrollment({ + makeCourseEnrollment({ run: { title: "A Course Ended", end_date: faker.date.past().toISOString(), }, }), - makeEnrollment({ + makeCourseEnrollment({ run: { title: "B Course Ended", end_date: faker.date.past().toISOString(), @@ -67,13 +67,13 @@ const setupEnrollments = (includeExpired: boolean) => { ] : [] const started = [ - makeEnrollment({ + makeCourseEnrollment({ run: { title: "A Course Started", start_date: faker.date.past().toISOString(), }, }), - makeEnrollment({ + makeCourseEnrollment({ run: { title: "B Course Started", start_date: faker.date.past().toISOString(), @@ -81,12 +81,12 @@ const setupEnrollments = (includeExpired: boolean) => { }), ] const notStarted = [ - makeEnrollment({ + makeCourseEnrollment({ run: { start_date: moment().add(1, "day").toISOString(), // Sooner first }, }), - makeEnrollment({ + makeCourseEnrollment({ run: { start_date: moment().add(5, "day").toISOString(), // Later second }, diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts index 0065d5eda5..b96af4ce58 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts @@ -55,7 +55,11 @@ const mitxonlineEnrollment = (raw: CourseRunEnrollment): DashboardCourse => { coursewareUrl: raw.run.courseware_url, certificate: { uuid: raw.certificate?.uuid ?? "", - link: raw.certificate?.link ?? "", + link: + raw.certificate?.link?.replace( + /^\/certificate\/([^/]+)\/$/, + "/certificate/course/$1/", + ) ?? "", }, }, enrollment: { diff --git a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx index f4275882e6..379f596741 100644 --- a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx @@ -24,6 +24,8 @@ describe("OrganizationContent", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) // Set default empty enrollments for all tests setMockResponse.get(urls.enrollment.enrollmentsList(), []) + // Add missing program enrollments mock + setMockResponse.get(urls.programEnrollments.enrollmentsList(), []) }) it("displays a header for each program returned and cards for courses in program", async () => { @@ -300,4 +302,41 @@ describe("OrganizationContent", () => { ) }) }) + + test("Shows the program certificate link button if the program has a certificate", async () => { + const { orgX, programA } = setupProgramsAndCourses() + + // Mock the program to have a certificate + const programWithCertificate = { + ...programA, + program_type: "Program", // Set specific program type + certificate: { + uuid: "cert-123", + url: "/certificates/program/1", + }, + } + const programEnrollment = factories.enrollment.programEnrollment({ + program: { id: programWithCertificate.id }, + certificate: { + link: programWithCertificate.certificate.url, + }, + }) + setMockResponse.get(urls.programs.programsList({ org_id: orgX.id }), { + results: [programWithCertificate], + }) + setMockResponse.get(urls.programEnrollments.enrollmentsList(), [ + programEnrollment, + ]) + + renderWithProviders() + + const programRoot = await screen.findByTestId("org-program-root") + const certificateButton = within(programRoot).getByRole("link", { + name: "View Program Certificate", + }) + expect(certificateButton).toHaveAttribute( + "href", + programWithCertificate.certificate.url, + ) + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx b/frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx index fb7283c24c..9a9fc29d35 100644 --- a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx @@ -23,8 +23,11 @@ import graduateLogo from "@/public/images/dashboard/graduate.png" import { CourseRunEnrollment, OrganizationPage, + UserProgramEnrollmentDetail, } from "@mitodl/mitxonline-api-axios/v2" import { useMitxOnlineCurrentUser } from "api/mitxonline-hooks/user" +import { ButtonLink } from "@mitodl/smoot-design" +import { RiAwardFill } from "@remixicon/react" const HeaderRoot = styled.div({ display: "flex", @@ -78,23 +81,45 @@ const DashboardCardStyled = styled(DashboardCard)({ borderRadius: "0px 0px 8px 8px", }, }) + const ProgramRoot = styled.div(({ theme }) => ({ color: theme.custom.colors.darkGray2, boxShadow: "0px 4px 8px 0px rgba(19, 20, 21, 0.08)", backgroundColor: theme.custom.colors.white, borderRadius: "8px", })) + const ProgramHeader = styled.div(({ theme }) => ({ display: "flex", padding: "24px", - flexDirection: "column", - + flexDirection: "row", + justifyContent: "space-between", gap: "16px", backgroundColor: theme.custom.colors.white, borderRadius: "8px 8px 0px 0px", border: `1px solid ${theme.custom.colors.lightGray2}`, borderBottom: `1px solid ${theme.custom.colors.red}`, + [theme.breakpoints.down("sm")]: { + flexDirection: "column", + }, })) + +const ProgramHeaderText = styled.div({ + flexDirection: "column", + gap: "8px", +}) + +const ProgramCertificateButton = styled(ButtonLink)(({ theme }) => ({ + color: theme.custom.colors.red, + display: "flex", + width: "192px", + height: "32px", + padding: "12px 12px 12px 8px", + justifyContent: "center", + alignItems: "center", + gap: "10px", +})) + const ProgramDescription = styled(Typography)({ p: { margin: 0, @@ -148,18 +173,24 @@ const OrgProgramCollectionDisplay: React.FC<{ const { isLoading, programsWithCourses, hasAnyCourses } = useProgramCollectionCourses(collection.programIds, orgId) + const header = ( + + + + {collection.title} + + + + + ) + if (isLoading) { return ( - - - {collection.title} - - - + {header} - - - {collection.title} - - - + {header} {programsWithCourses.map((item) => item ? ( @@ -206,9 +229,20 @@ const OrgProgramCollectionDisplay: React.FC<{ const OrgProgramDisplay: React.FC<{ program: DashboardProgram courseRunEnrollments?: CourseRunEnrollment[] + programEnrollments?: UserProgramEnrollmentDetail[] programLoading: boolean orgId: number -}> = ({ program, courseRunEnrollments, programLoading, orgId }) => { +}> = ({ + program, + courseRunEnrollments, + programEnrollments, + programLoading, + orgId, +}) => { + const programEnrollment = programEnrollments?.find( + (enrollment) => enrollment.program.id === program.id, + ) + const hasValidCertificate = !!programEnrollment?.certificate const courses = useQuery( coursesQueries.coursesList({ id: program.courseIds, org_id: orgId }), ) @@ -225,13 +259,25 @@ const OrgProgramDisplay: React.FC<{ return ( - - {program.title} - - + + + {program.title} + + + + {hasValidCertificate && ( + } + href={programEnrollment?.certificate?.link} + > + View {program.programType} Certificate + + )} {transform @@ -316,6 +362,9 @@ const OrganizationContentInternal: React.FC< const courseRunEnrollments = useQuery( enrollmentQueries.courseRunEnrollmentsList(), ) + const programEnrollments = useQuery( + enrollmentQueries.programEnrollmentsList(), + ) const programs = useQuery(programsQueries.programsList({ org_id: orgId })) const programCollections = useQuery( programCollectionQueries.programCollectionsList({}), @@ -345,6 +394,7 @@ const OrganizationContentInternal: React.FC< key={program.key} program={program} courseRunEnrollments={courseRunEnrollments.data} + programEnrollments={programEnrollments.data} programLoading={programs.isLoading} orgId={orgId} /> diff --git a/frontends/main/src/app-pages/ErrorPage/ErrorPageTemplate.tsx b/frontends/main/src/app-pages/ErrorPage/ErrorPageTemplate.tsx index f77e04b1b7..173e2aac8d 100644 --- a/frontends/main/src/app-pages/ErrorPage/ErrorPageTemplate.tsx +++ b/frontends/main/src/app-pages/ErrorPage/ErrorPageTemplate.tsx @@ -13,7 +13,7 @@ type ErrorPageTemplateProps = { loading?: boolean } -const Page = styled(Container)(({ theme }) => ({ +const Page = styled.div(({ theme }) => ({ backgroundImage: `url(${backgroundImage.src})`, backgroundAttachment: "fixed", backgroundRepeat: "no-repeat", @@ -25,7 +25,7 @@ const Page = styled(Container)(({ theme }) => ({ }, })) -const ErrorContainer = styled.div(({ theme }) => ({ +const ErrorContainer = styled(Container)(({ theme }) => ({ padding: "56px 0", display: "flex", flexDirection: "column", diff --git a/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx b/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx new file mode 100644 index 0000000000..044e755952 --- /dev/null +++ b/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx @@ -0,0 +1,31 @@ +import React from "react" +import { notFound } from "next/navigation" +import CertificatePage from "@/app-pages/CertificatePage/CertificatePage" + +enum CertificateType { + Course = "course", + Program = "program", +} + +interface PageProps { + params: Promise<{ + certificateType: CertificateType + uuid: string + }> +} + +const Page: React.FC = async ({ params }) => { + const { certificateType } = await params + + if ( + ![CertificateType.Course, CertificateType.Program].includes( + certificateType as CertificateType, + ) + ) { + notFound() + } + + return +} + +export default Page diff --git a/learning_resources/etl/utils.py b/learning_resources/etl/utils.py index 06a31682d3..2d770f2507 100644 --- a/learning_resources/etl/utils.py +++ b/learning_resources/etl/utils.py @@ -463,14 +463,15 @@ def get_url_from_module_id( if run.learning_resource.etl_source == ETLSource.oll.value else run.run_id ) + base_jump_url = f"{root_url}/courses/{run_id}/jump_to_id/" if module_id.startswith("asset"): video_meta = video_srt_metadata.get(module_id, {}) if video_srt_metadata else {} if video_meta: # Link to the parent video - return f"{root_url}/courses/{run_id}/jump_to/{video_meta.split('@')[-1]}" + return f"{base_jump_url}{video_meta.split('@')[-1]}" return f"{root_url}/{module_id}" elif module_id.startswith("block") and is_valid_uuid(module_id.split("@")[-1]): - return f"{root_url}/courses/{run_id}/jump_to_id/{module_id.split('@')[-1]}" + return f"{base_jump_url}{module_id.split('@')[-1]}" else: return None diff --git a/learning_resources/etl/utils_test.py b/learning_resources/etl/utils_test.py index 1ad8631436..52737c62e8 100644 --- a/learning_resources/etl/utils_test.py +++ b/learning_resources/etl/utils_test.py @@ -715,7 +715,7 @@ def test_get_video_metadata(mocker, tmp_path, video_dir_exists): "mit_edx", "asset-v1:test+type@asset+block@transcript.srt", True, - "https://edx.org/courses/course-v1:test_run/jump_to/test_video", + "https://edx.org/courses/course-v1:test_run/jump_to_id/test_video", ), ( "mit_edx", diff --git a/main/settings.py b/main/settings.py index 313a791095..972f015718 100644 --- a/main/settings.py +++ b/main/settings.py @@ -34,7 +34,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.41.1" +VERSION = "0.41.2" log = logging.getLogger() diff --git a/yarn.lock b/yarn.lock index d080767835..9da5db8882 100755 --- a/yarn.lock +++ b/yarn.lock @@ -3028,6 +3028,16 @@ __metadata: languageName: node linkType: hard +"@mitodl/mitxonline-api-axios@npm:2025.8.18": + version: 2025.8.18 + resolution: "@mitodl/mitxonline-api-axios@npm:2025.8.18" + dependencies: + "@types/node": "npm:^20.11.19" + axios: "npm:^1.6.5" + checksum: 10/91ff2d63fd251ef07dcea8c34497ddbd3b24046ba639b5ac6819d7bcdef560ea4838ecfd2e8db7876d4611072c7d37250d0bd8bc7e643d96d17d61b4db0c192e + languageName: node + linkType: hard + "@mitodl/open-api-axios@npm:2024.9.16": version: 2024.9.16 resolution: "@mitodl/open-api-axios@npm:2024.9.16" @@ -7363,7 +7373,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^9.9.0" - "@mitodl/mitxonline-api-axios": "npm:2025.8.12" + "@mitodl/mitxonline-api-axios": "npm:2025.8.18" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.1.0" axios: "npm:^1.6.3"