diff --git a/frontends/api/src/clients.ts b/frontends/api/src/clients.ts index 23dd4216a1..bd60b54a5b 100644 --- a/frontends/api/src/clients.ts +++ b/frontends/api/src/clients.ts @@ -3,6 +3,7 @@ import { LearningpathsApi, TopicsApi, ArticlesApi, + ProgramLettersApi, } from "./generated/api" import axiosInstance from "./axios" @@ -23,4 +24,15 @@ const topicsApi = new TopicsApi(undefined, BASE_PATH, axiosInstance) const articlesApi = new ArticlesApi(undefined, BASE_PATH, axiosInstance) -export { learningResourcesApi, learningpathsApi, topicsApi, articlesApi } +const programLettersApi = new ProgramLettersApi( + undefined, + BASE_PATH, + axiosInstance, +) +export { + learningResourcesApi, + learningpathsApi, + topicsApi, + articlesApi, + programLettersApi, +} diff --git a/frontends/api/src/generated/api.ts b/frontends/api/src/generated/api.ts index 7b4a9c88a5..3511724753 100644 --- a/frontends/api/src/generated/api.ts +++ b/frontends/api/src/generated/api.ts @@ -2940,6 +2940,219 @@ export interface Program { */ courses: Array | null } +/** + * Serializer for Program Certificates + * @export + * @interface ProgramCertificate + */ +export interface ProgramCertificate { + /** + * + * @type {number} + * @memberof ProgramCertificate + */ + id: number + /** + * + * @type {number} + * @memberof ProgramCertificate + */ + user_edxorg_id?: number | null + /** + * + * @type {number} + * @memberof ProgramCertificate + */ + micromasters_program_id?: number | null + /** + * + * @type {number} + * @memberof ProgramCertificate + */ + mitxonline_program_id?: number | null + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_edxorg_username?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_email: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + program_title: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_gender?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_address_city?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_first_name?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_last_name?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_full_name?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_year_of_birth?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_country?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_address_postal_code?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_street_address?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_address_state_or_territory?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + user_mitxonline_username?: string + /** + * + * @type {string} + * @memberof ProgramCertificate + */ + program_completion_timestamp?: string | null +} +/** + * Serializer for Program Letters + * @export + * @interface ProgramLetter + */ +export interface ProgramLetter { + /** + * + * @type {string} + * @memberof ProgramLetter + */ + id: string + /** + * + * @type {ProgramLetterTemplateField} + * @memberof ProgramLetter + */ + template_fields: ProgramLetterTemplateField + /** + * + * @type {ProgramCertificate} + * @memberof ProgramLetter + */ + certificate: ProgramCertificate +} +/** + * Seriializer for program letter template data which is configured in micromasters + * @export + * @interface ProgramLetterTemplateField + */ +export interface ProgramLetterTemplateField { + /** + * + * @type {number} + * @memberof ProgramLetterTemplateField + */ + id: number + /** + * + * @type {any} + * @memberof ProgramLetterTemplateField + */ + meta: any + /** + * + * @type {string} + * @memberof ProgramLetterTemplateField + */ + title: string + /** + * + * @type {number} + * @memberof ProgramLetterTemplateField + */ + program_id: number + /** + * + * @type {any} + * @memberof ProgramLetterTemplateField + */ + program_letter_footer: any + /** + * + * @type {string} + * @memberof ProgramLetterTemplateField + */ + program_letter_footer_text: string + /** + * + * @type {string} + * @memberof ProgramLetterTemplateField + */ + program_letter_header_text: string + /** + * + * @type {string} + * @memberof ProgramLetterTemplateField + */ + program_letter_text: string + /** + * + * @type {any} + * @memberof ProgramLetterTemplateField + */ + program_letter_logo: any + /** + * + * @type {Array} + * @memberof ProgramLetterTemplateField + */ + program_letter_signatories: Array +} /** * Serializer for program resources * @export @@ -14426,6 +14639,169 @@ export const PodcastsListSortbyEnum = { export type PodcastsListSortbyEnum = (typeof PodcastsListSortbyEnum)[keyof typeof PodcastsListSortbyEnum] +/** + * ProgramLettersApi - axios parameter creator + * @export + */ +export const ProgramLettersApiAxiosParamCreator = function ( + configuration?: Configuration, +) { + return { + /** + * Retrieve a single program letter. + * @summary Retrieve + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + programLettersRetrieve: async ( + id: string, + options: RawAxiosRequestConfig = {}, + ): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists("programLettersRetrieve", "id", id) + const localVarPath = `/api/v1/program_letters/{id}/`.replace( + `{${"id"}}`, + encodeURIComponent(String(id)), + ) + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + } +} + +/** + * ProgramLettersApi - functional programming interface + * @export + */ +export const ProgramLettersApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = + ProgramLettersApiAxiosParamCreator(configuration) + return { + /** + * Retrieve a single program letter. + * @summary Retrieve + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async programLettersRetrieve( + id: string, + options?: RawAxiosRequestConfig, + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.programLettersRetrieve(id, options) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["ProgramLettersApi.programLettersRetrieve"]?.[index] + ?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, + } +} + +/** + * ProgramLettersApi - factory interface + * @export + */ +export const ProgramLettersApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance, +) { + const localVarFp = ProgramLettersApiFp(configuration) + return { + /** + * Retrieve a single program letter. + * @summary Retrieve + * @param {ProgramLettersApiProgramLettersRetrieveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + programLettersRetrieve( + requestParameters: ProgramLettersApiProgramLettersRetrieveRequest, + options?: RawAxiosRequestConfig, + ): AxiosPromise { + return localVarFp + .programLettersRetrieve(requestParameters.id, options) + .then((request) => request(axios, basePath)) + }, + } +} + +/** + * Request parameters for programLettersRetrieve operation in ProgramLettersApi. + * @export + * @interface ProgramLettersApiProgramLettersRetrieveRequest + */ +export interface ProgramLettersApiProgramLettersRetrieveRequest { + /** + * + * @type {string} + * @memberof ProgramLettersApiProgramLettersRetrieve + */ + readonly id: string +} + +/** + * ProgramLettersApi - object-oriented interface + * @export + * @class ProgramLettersApi + * @extends {BaseAPI} + */ +export class ProgramLettersApi extends BaseAPI { + /** + * Retrieve a single program letter. + * @summary Retrieve + * @param {ProgramLettersApiProgramLettersRetrieveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProgramLettersApi + */ + public programLettersRetrieve( + requestParameters: ProgramLettersApiProgramLettersRetrieveRequest, + options?: RawAxiosRequestConfig, + ) { + return ProgramLettersApiFp(this.configuration) + .programLettersRetrieve(requestParameters.id, options) + .then((request) => request(this.axios, this.basePath)) + } +} + /** * ProgramsApi - axios parameter creator * @export diff --git a/frontends/api/src/hooks/programLetters/index.ts b/frontends/api/src/hooks/programLetters/index.ts new file mode 100644 index 0000000000..a9de8224cc --- /dev/null +++ b/frontends/api/src/hooks/programLetters/index.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query" +import programLetters from "./keyFactory" + +/** + * Query is diabled if id is undefined. + */ +const useProgramLettersDetail = (id: string | undefined) => { + return useQuery({ + ...programLetters.detail(id ?? ""), + enabled: id !== undefined, + }) +} + +export { useProgramLettersDetail } diff --git a/frontends/api/src/hooks/programLetters/keyFactory.ts b/frontends/api/src/hooks/programLetters/keyFactory.ts new file mode 100644 index 0000000000..91830478c7 --- /dev/null +++ b/frontends/api/src/hooks/programLetters/keyFactory.ts @@ -0,0 +1,16 @@ +import { programLettersApi } from "../../clients" +import { createQueryKeys } from "@lukemorales/query-key-factory" + +const programLetters = createQueryKeys("programLetters", { + detail: (id: string) => ({ + queryKey: [id], + queryFn: () => { + if (id === "") return Promise.reject("Invalid ID") + return programLettersApi + .programLettersRetrieve({ id }) + .then((res) => res.data) + }, + }), +}) + +export default programLetters diff --git a/frontends/api/src/test-utils/factories/index.ts b/frontends/api/src/test-utils/factories/index.ts index b58161df8c..e0327bfa44 100644 --- a/frontends/api/src/test-utils/factories/index.ts +++ b/frontends/api/src/test-utils/factories/index.ts @@ -1,3 +1,4 @@ export * as learningResources from "./learningResources" export * as articles from "./articles" +export * as letters from "./programLetters" diff --git a/frontends/api/src/test-utils/factories/programLetters.ts b/frontends/api/src/test-utils/factories/programLetters.ts new file mode 100644 index 0000000000..514230882b --- /dev/null +++ b/frontends/api/src/test-utils/factories/programLetters.ts @@ -0,0 +1,46 @@ +import { faker } from "@faker-js/faker/locale/en" +import type { Factory } from "ol-test-utilities" +import type { ProgramLetter } from "../../generated" + +const programLetter: Factory = (overrides = {}) => ({ + id: faker.datatype.uuid(), + template_fields: { + meta: {}, + id: faker.datatype.number(), + program_id: faker.datatype.number(), + title: faker.lorem.words(), + program_letter_logo: { + meta: { + download_url: faker.image.imageUrl(), + }, + }, + program_letter_text: faker.lorem.paragraph(), + program_letter_footer: {}, + program_letter_footer_text: faker.lorem.paragraph(), + program_letter_header_text: faker.lorem.paragraph(), + program_letter_signatories: [ + { + id: faker.datatype.number(), + name: faker.name.fullName(), + title_line_1: faker.name.jobTitle(), + signature_image: { + meta: { + download_url: faker.image.imageUrl(), + }, + }, + }, + ], + }, + certificate: { + id: faker.datatype.number(), + micromasters_program_id: faker.datatype.number(), + user_email: faker.internet.email(), + program_title: faker.lorem.words(), + user_first_name: faker.name.firstName(), + user_last_name: faker.name.lastName(), + user_full_name: faker.name.fullName(), + }, + ...overrides, +}) + +export { programLetter } diff --git a/frontends/api/src/test-utils/urls.ts b/frontends/api/src/test-utils/urls.ts index d87841d2f2..8402c32699 100644 --- a/frontends/api/src/test-utils/urls.ts +++ b/frontends/api/src/test-utils/urls.ts @@ -63,4 +63,8 @@ const articles = { details: (id: number) => `/api/v1/articles/${id}/`, } -export { learningResources, topics, learningPaths, articles } +const programLetters = { + details: (id: string) => `/api/v1/program_letters/${id}/`, +} + +export { learningResources, topics, learningPaths, articles, programLetters } diff --git a/frontends/mit-open/src/common/urls.ts b/frontends/mit-open/src/common/urls.ts index 02be5189ae..39c8199afd 100644 --- a/frontends/mit-open/src/common/urls.ts +++ b/frontends/mit-open/src/common/urls.ts @@ -6,7 +6,9 @@ export const LEARNINGPATH_LISTING = "/learningpaths/" export const LEARNINGPATH_VIEW = "/learningpaths/:id" export const learningPathsView = (id: number) => generatePath(LEARNINGPATH_VIEW, { id: String(id) }) - +export const PROGRAMLETTER_VIEW = "/program_letter/:id/view/" +export const programLetterView = (id: string) => + generatePath(PROGRAMLETTER_VIEW, { id: String(id) }) export const ARTICLES_LISTING = "/articles/" export const ARTICLES_DETAILS = "/articles/:id" export const ARTICLES_EDIT = "/articles/:id/edit" diff --git a/frontends/mit-open/src/pages/ProgramLetterPage/ProgramLetter.test.tsx b/frontends/mit-open/src/pages/ProgramLetterPage/ProgramLetter.test.tsx new file mode 100644 index 0000000000..3aecfb2a41 --- /dev/null +++ b/frontends/mit-open/src/pages/ProgramLetterPage/ProgramLetter.test.tsx @@ -0,0 +1,37 @@ +import { renderTestApp, waitFor } from "../../test-utils" +import type { ProgramLetter } from "api" +import { letters as factory } from "api/test-utils/factories" +import { setMockResponse, urls } from "api/test-utils" +import { programLetterView } from "@/common/urls" + +const setup = ({ programLetter }: { programLetter: ProgramLetter }) => { + setMockResponse.get( + urls.programLetters.details(programLetter.id), + programLetter, + ) + renderTestApp({ + url: programLetterView(programLetter.id), + }) +} + +describe("ProgramLetterDisplayPage", () => { + it("Renders a program letter from api", async () => { + const programLetter = factory.programLetter() + setup({ programLetter }) + await waitFor(() => { + const letterText = document.querySelector(".letter-text > .ck-content") + expect(letterText?.innerHTML).toBe( + programLetter?.template_fields?.program_letter_text, + ) + }) + await waitFor(() => { + const signatureImage = document.querySelector( + ".sig-image > img", + ) as HTMLImageElement + expect(signatureImage?.src).toBe( + programLetter?.template_fields?.program_letter_signatories![0] + .signature_image?.meta?.download_url, + ) + }) + }) +}) diff --git a/frontends/mit-open/src/pages/ProgramLetterPage/ProgramLetterPage.tsx b/frontends/mit-open/src/pages/ProgramLetterPage/ProgramLetterPage.tsx new file mode 100644 index 0000000000..d2ecfcc33e --- /dev/null +++ b/frontends/mit-open/src/pages/ProgramLetterPage/ProgramLetterPage.tsx @@ -0,0 +1,173 @@ +import React from "react" +import { styled } from "ol-components" +import { useProgramLettersDetail } from "api/hooks/programLetters" +import { useParams } from "react-router" +import { CkeditorDisplay } from "ol-ckeditor" + +type RouteParams = { + id: string +} + +const ProgramLetterPageContainer = styled.div` + background: #fff; + max-width: 800px; + margin-left: auto; + margin-right: auto; + padding: 50px 60px; + + .letter-content { + margin-top: 50px; + } + + .letter-logo > img { + max-width: 300px; + max-height: 150px; + } + + .footer-logo > img { + max-width: 300px; + max-height: 150px; + } +` + +const ProgramLetterHeader = styled.div` + display: flex; + + .header-text { + width: 50%; + } + + .header-text p { + font-size: 14px; + line-height: 1.2em; + margin: 0; + } + + .header-text h2, + .header-text h3, + .header-text h4 { + font-size: 18px; + margin: 0; + } + + .letter-logo { + width: 50%; + text-align: end; + } +` + +const ProgramLetterSignatures = styled.div` + .signatory { + margin: 10px 0 5px; + } + + .sig-image { + max-height: 80px; + margin-bottom: 3px; + } + + .sig-image > img { + max-height: 60px; + max-width: 130px; + } +` + +const ProgramLetterFooter = styled.div` + margin-top: 40px; + display: flex; + + .footer-logo { + width: 50%; + } + + .footer-text { + font-size: 12px; + text-align: end; + width: 100%; + } + + .footer-text p { + line-height: 0.5em; + } + + .footer-text h2, + .footer-text h3, + .footer-text h4 { + font-size: 13px; + margin-top: 0; + } + + .program-footer img { + max-height: 50px; + max-width: 300px; + } +` + +const ProgramLetterPage: React.FC = () => { + const id = String(useParams().id) + const programLetter = useProgramLettersDetail(id) + const templateFields = programLetter.data?.template_fields + const certificateInfo = programLetter.data?.certificate + + return ( + + +
+ +
+
+ +
+
+
+ Dear {certificateInfo?.user_full_name}, +
+ +
+ + {templateFields?.program_letter_signatories?.map((signatory) => ( +
+
+ +
+
+ {signatory.name},{signatory.title_line_1} + {signatory.title_line_2 ? ( +

, {signatory.title_line_2}

+ ) : ( +

+ )} +
+
+ ))} +
+
+ +
+ {templateFields?.program_letter_footer ? ( + + ) : ( +

MITx MicroMasters program in {templateFields?.title}

+ )} +
+
+ +
+
+
+ ) +} + +export default ProgramLetterPage diff --git a/frontends/mit-open/src/routes.tsx b/frontends/mit-open/src/routes.tsx index e717a3d703..5ae7a0800e 100644 --- a/frontends/mit-open/src/routes.tsx +++ b/frontends/mit-open/src/routes.tsx @@ -6,6 +6,7 @@ import LearningPathListingPage from "@/pages/LearningPathListingPage/LearningPat import LearningPathDetailsPage from "@/pages/LearningPathDetailsPage/LearningPathDetailsPage" import ArticleDetailsPage from "@/pages/ArticleDetailsPage/ArticleDetailsPage" import { ArticleCreatePage, ArticleEditPage } from "@/pages/ArticleUpsertPages" +import ProgramLetterPage from "@/pages/ProgramLetterPage/ProgramLetterPage" import DashboardPage from "@/pages/DashboardPage/DashboardPage" import ErrorPage from "@/pages/ErrorPage/ErrorPage" import * as urls from "@/common/urls" @@ -43,6 +44,10 @@ const routes: RouteObject[] = [ ), }, + { + path: urls.PROGRAMLETTER_VIEW, + element: , + }, { element: , children: [ diff --git a/learning_resources/urls.py b/learning_resources/urls.py index 54f4f91b71..82d0c99e97 100644 --- a/learning_resources/urls.py +++ b/learning_resources/urls.py @@ -6,6 +6,7 @@ from learning_resources import views from learning_resources.views import WebhookOCWView +from profiles.views import ProgramLetterViewSet router = SimpleRouter() router.register( @@ -13,7 +14,9 @@ views.LearningResourceViewSet, basename="learning_resources_api", ) - +router.register( + r"program_letters", ProgramLetterViewSet, basename="program_letters_api" +) nested_learning_resources_router = NestedSimpleRouter( router, r"learning_resources", lookup="learning_resource" ) diff --git a/main/settings.py b/main/settings.py index 2aaec687a1..38d5b436f1 100644 --- a/main/settings.py +++ b/main/settings.py @@ -679,3 +679,5 @@ def get_all_config_keys(): name="KEYCLOAK_REALM_NAME", default="olapps", ) + +MICROMASTERS_CMS_API_URL = get_string("MICROMASTERS_CMS_API_URL", None) diff --git a/main/urls.py b/main/urls.py index 22286e7216..752d5e6090 100644 --- a/main/urls.py +++ b/main/urls.py @@ -52,6 +52,7 @@ re_path(r"^learningpaths/", index, name="learningpaths"), re_path(r"^articles/", index, name="articles"), re_path(r"^dashboard/", index, name="dashboard"), + re_path(r"^program_letter/", index, name="programletter"), # Hijack re_path(r"^hijack/", include("hijack.urls", namespace="hijack")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 31e7b8df77..7cb4fd28a2 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -4424,6 +4424,26 @@ paths: schema: $ref: '#/components/schemas/LearningResourceRelationship' description: '' + /api/v1/program_letters/{id}/: + get: + operationId: program_letters_retrieve + description: Retrieve a single program letter. + summary: Retrieve + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - program_letters + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ProgramLetter' + description: '' /api/v1/programs/: get: operationId: programs_list @@ -7713,6 +7733,131 @@ components: readOnly: true required: - courses + ProgramCertificate: + type: object + description: Serializer for Program Certificates + properties: + id: + type: integer + readOnly: true + user_edxorg_id: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + micromasters_program_id: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + mitxonline_program_id: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + user_edxorg_username: + type: string + maxLength: 256 + user_email: + type: string + maxLength: 256 + program_title: + type: string + maxLength: 256 + user_gender: + type: string + maxLength: 256 + user_address_city: + type: string + maxLength: 256 + user_first_name: + type: string + maxLength: 256 + user_last_name: + type: string + maxLength: 256 + user_full_name: + type: string + maxLength: 256 + user_year_of_birth: + type: string + maxLength: 256 + user_country: + type: string + maxLength: 256 + user_address_postal_code: + type: string + maxLength: 256 + user_street_address: + type: string + maxLength: 256 + user_address_state_or_territory: + type: string + maxLength: 256 + user_mitxonline_username: + type: string + maxLength: 256 + program_completion_timestamp: + type: string + format: date-time + nullable: true + required: + - id + - program_title + - user_email + ProgramLetter: + type: object + description: Serializer for Program Letters + properties: + id: + type: string + format: uuid + readOnly: true + template_fields: + allOf: + - $ref: '#/components/schemas/ProgramLetterTemplateField' + readOnly: true + certificate: + $ref: '#/components/schemas/ProgramCertificate' + required: + - certificate + - id + - template_fields + ProgramLetterTemplateField: + type: object + description: |- + Seriializer for program letter template data which is configured in + micromasters + properties: + id: + type: integer + meta: {} + title: + type: string + program_id: + type: integer + program_letter_footer: {} + program_letter_footer_text: + type: string + program_letter_header_text: + type: string + program_letter_text: + type: string + program_letter_logo: {} + program_letter_signatories: + type: array + items: {} + required: + - id + - meta + - program_id + - program_letter_footer + - program_letter_footer_text + - program_letter_header_text + - program_letter_logo + - program_letter_signatories + - program_letter_text + - title ProgramResource: type: object description: Serializer for program resources diff --git a/profiles/factories.py b/profiles/factories.py index 85feaf310b..dbeb6eac20 100644 --- a/profiles/factories.py +++ b/profiles/factories.py @@ -4,7 +4,7 @@ from factory.django import DjangoModelFactory, ImageField from faker.providers import BaseProvider -from profiles.models import Profile, UserWebsite +from profiles.models import Profile, ProgramCertificate, ProgramLetter, UserWebsite class LocationProvider(BaseProvider): @@ -54,3 +54,16 @@ class UserWebsiteFactory(DjangoModelFactory): class Meta: model = UserWebsite + + +class ProgramCertificateFactory(DjangoModelFactory): + user_full_name = Faker("name") + user_email = Faker("email") + + class Meta: + model = ProgramCertificate + + +class ProgramLetterFactory(DjangoModelFactory): + class Meta: + model = ProgramLetter diff --git a/profiles/migrations/0017_programletter.py b/profiles/migrations/0017_programletter.py new file mode 100644 index 0000000000..c1101ef226 --- /dev/null +++ b/profiles/migrations/0017_programletter.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.10 on 2024-03-06 14:15 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("profiles", "0016_alter_programcertificate_options"), + ] + + operations = [ + migrations.CreateModel( + name="ProgramLetter", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "certificate", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="profiles.programcertificate", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/profiles/models.py b/profiles/models.py index 75ffa85dd9..a6ca9ea62b 100644 --- a/profiles/models.py +++ b/profiles/models.py @@ -3,6 +3,7 @@ from uuid import uuid4 from django.conf import settings +from django.contrib.auth import get_user_model from django.db import models, transaction from django.db.models import JSONField from django_scim.models import AbstractSCIMUserMixin @@ -43,6 +44,8 @@ LINKEDIN_DOMAIN: "LinkedIn", } +User = get_user_model() + def filter_profile_props(data): """ @@ -179,4 +182,23 @@ class Meta: db_table = "external.programcertificate" def __str__(self): - return "program certificate: {self.user_full_name} - {self.program_title}" + return f"program certificate: {self.user_full_name} - {self.program_title}" + + +class ProgramLetter(models.Model): + """ + Class used to generate program letter views + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE) + certificate = models.ForeignKey(ProgramCertificate, on_delete=models.CASCADE) + + def __str__(self): + return ( + "program letter: " + f"{self.certificate.user_full_name} - {self.certificate.program_title}" + ) + + def get_absolute_url(self): + return f"/program_letter/{self.id}/view" diff --git a/profiles/models_test.py b/profiles/models_test.py index b1f0d6e6c6..e4e2fa5d88 100644 --- a/profiles/models_test.py +++ b/profiles/models_test.py @@ -4,7 +4,12 @@ from django.core.files.uploadedfile import UploadedFile from django.db import connection -from profiles.models import PERSONAL_SITE_TYPE, SITE_TYPE_OPTIONS, SOCIAL_SITE_NAME_MAP +from profiles.factories import ProgramCertificateFactory, ProgramLetterFactory +from profiles.models import ( + PERSONAL_SITE_TYPE, + SITE_TYPE_OPTIONS, + SOCIAL_SITE_NAME_MAP, +) @pytest.mark.parametrize("update_image", [True, False]) @@ -67,3 +72,17 @@ def test_external_schema_exists(): """ ) assert cursor.fetchone()[0] == 1 + + +@pytest.mark.django_db() +def test_program_letter_model_strings(user): + """ + Test that ProgramCertificate and ProgramLetter string methods + return what we expect + """ + cert = ProgramCertificateFactory( + user_full_name="test user", program_title="test program" + ) + letter = ProgramLetterFactory(user=user, certificate=cert) + assert str(cert) == "program certificate: test user - test program" + assert str(letter) == "program letter: test user - test program" diff --git a/profiles/serializers.py b/profiles/serializers.py index c814adce53..7583dce2a8 100644 --- a/profiles/serializers.py +++ b/profiles/serializers.py @@ -7,6 +7,7 @@ import ulid from django.contrib.auth import get_user_model from django.db import transaction +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -17,9 +18,16 @@ PROFILE_PROPS, SOCIAL_SITE_NAME_MAP, Profile, + ProgramCertificate, + ProgramLetter, UserWebsite, ) -from profiles.utils import IMAGE_MEDIUM, IMAGE_SMALL, image_uri +from profiles.utils import ( + IMAGE_MEDIUM, + IMAGE_SMALL, + fetch_program_letter_template_data, + image_uri, +) User = get_user_model() @@ -244,3 +252,54 @@ class Meta: model = User fields = ("id", "username", "profile", "email") read_only_fields = ("id", "username") + + +class ProgramCertificateSerializer(serializers.ModelSerializer): + """ + Serializer for Program Certificates + """ + + class Meta: + model = ProgramCertificate + fields = "__all__" + + +class ProgramLetterTemplateFieldSerializer(serializers.Serializer): + """ + Seriializer for program letter template data which is configured in + micromasters + """ + + id = serializers.IntegerField() + meta = serializers.JSONField() + title = serializers.CharField() + program_id = serializers.IntegerField() + program_letter_footer = serializers.JSONField() + program_letter_footer_text = serializers.CharField() + program_letter_header_text = serializers.CharField() + program_letter_text = serializers.CharField() + program_letter_logo = serializers.JSONField() + program_letter_signatories = serializers.ListField(child=serializers.JSONField()) + + +class ProgramLetterSerializer(serializers.ModelSerializer): + """ + Serializer for Program Letters + """ + + id = serializers.UUIDField(read_only=True) + + template_fields = serializers.SerializerMethodField() + + certificate = ProgramCertificateSerializer() + + @extend_schema_field(ProgramLetterTemplateFieldSerializer()) + def get_template_fields(self, instance) -> dict: + """Get template fields from the micromasters cms api""" + return ProgramLetterTemplateFieldSerializer( + fetch_program_letter_template_data(instance) + ).data + + class Meta: + model = ProgramLetter + fields = ["id", "template_fields", "certificate"] diff --git a/profiles/urls.py b/profiles/urls.py index c9917fd8c5..40440e54ac 100644 --- a/profiles/urls.py +++ b/profiles/urls.py @@ -1,11 +1,12 @@ """URL configurations for profiles""" -from django.urls import include, re_path +from django.urls import include, path, re_path from rest_framework.routers import DefaultRouter from profiles.views import ( CurrentUserRetrieveViewSet, ProfileViewSet, + ProgramLetterInterceptView, UserViewSet, UserWebsiteViewSet, name_initials_avatar_view, @@ -16,6 +17,7 @@ router.register(r"profiles", ProfileViewSet, basename="profile_api") router.register(r"websites", UserWebsiteViewSet, basename="user_websites_api") + v0_urls = [ re_path( r"^users/me/$", @@ -34,4 +36,9 @@ name_initials_avatar_view, name="name-initials-avatar", ), + path( + "program_letter//", + ProgramLetterInterceptView.as_view(), + name="program-letter-intercept", + ), ] diff --git a/profiles/utils.py b/profiles/utils.py index 0bac78720e..125dcbafe1 100644 --- a/profiles/utils.py +++ b/profiles/utils.py @@ -2,11 +2,13 @@ import hashlib import re +import uuid from contextlib import contextmanager from io import BytesIO from urllib.parse import quote, urljoin from xml.sax.saxutils import escape as xml_escape +import requests from django.conf import settings from django.core.files.temp import NamedTemporaryFile from PIL import Image @@ -340,3 +342,32 @@ def generate_svg_avatar(name, size, color, bgcolor): "text": xml_escape(initials.upper()), } ).replace("\n", "") + + +def validate_uuid(val): + """ + Check the validity of uuid strings + """ + try: + if uuid.UUID(str(val)): + return True + except ValueError: + return False + + +def fetch_program_letter_template_data(letter): + """ + Fetch program letter template snippets directly + from micromasters + """ + if settings.MICROMASTERS_CMS_API_URL: + api_params = { + "type": "cms.ProgramPage", + "fields": "*", + "program_id": letter.certificate.micromasters_program_id, + } + api_url = urljoin(settings.MICROMASTERS_CMS_API_URL, "pages/") + response_json = requests.get(api_url, api_params, timeout=5).json() + if response_json and response_json.get("meta", {}).get("total_count", 0) > 0: + return response_json["items"][0] + return None diff --git a/profiles/utils_test.py b/profiles/utils_test.py index a49288d688..9daadadc09 100644 --- a/profiles/utils_test.py +++ b/profiles/utils_test.py @@ -5,12 +5,18 @@ from urllib.parse import parse_qs, urlparse import pytest +from django.conf import settings from PIL import Image from main.factories import UserFactory from main.utils import generate_filepath +from profiles.factories import ( + ProgramCertificateFactory, + ProgramLetterFactory, +) from profiles.utils import ( DEFAULT_PROFILE_IMAGE, + fetch_program_letter_template_data, generate_initials, generate_svg_avatar, image_uri, @@ -181,3 +187,41 @@ def test_get_svg_avatar(): def test_generate_initials(text, initials): """Test that expected initials are returned from text""" assert generate_initials(text) == initials + + +@pytest.mark.django_db() +def test_fetch_program_letter_template_data_malformed_api_response(mocker, user): + """ + Tests that a malformed response from micromasters api + causes fetch_program_letter_template_data to return None + """ + settings.MICROMASTERS_CMS_API_URL = "http://test.com" + mm_api_response = mocker.Mock() + mm_api_response.configure_mock(**{"json.return_value": {"some": "json"}}) + mocker.patch("requests.get", return_value=mm_api_response) + cert = ProgramCertificateFactory(user_email=user.email, micromasters_program_id=1) + program_letter = ProgramLetterFactory(user=user, certificate=cert) + assert fetch_program_letter_template_data(program_letter) is None + + +@pytest.mark.django_db() +def test_fetch_program_letter_template_data_has_results(mocker, user): + """ + Tests that a response from micromasters api + with a result returns properly + """ + settings.MICROMASTERS_CMS_API_URL = "http://test.com" + expected_item = {"test": "test"} + mm_api_response = mocker.Mock() + mm_api_response.configure_mock( + **{ + "json.return_value": { + "meta": {"total_count": 1}, + "items": [expected_item], + }, + } + ) + mocker.patch("requests.get", return_value=mm_api_response) + cert = ProgramCertificateFactory(user_email=user.email, micromasters_program_id=1) + program_letter = ProgramLetterFactory(user=user, certificate=cert) + assert fetch_program_letter_template_data(program_letter) == expected_item diff --git a/profiles/views.py b/profiles/views.py index aad94a885f..0c96c9563c 100644 --- a/profiles/views.py +++ b/profiles/views.py @@ -2,26 +2,35 @@ from cairosvg import svg2png # pylint:disable=no-name-in-module from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User -from django.http import HttpResponse -from django.shortcuts import redirect +from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect +from django.utils.decorators import method_decorator +from django.views import View from django.views.decorators.cache import cache_page -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import mixins, viewsets from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from main.permissions import ( AnonymousAccessReadonlyPermission, IsStaffPermission, ) -from profiles.models import Profile, UserWebsite +from profiles.models import Profile, ProgramCertificate, ProgramLetter, UserWebsite from profiles.permissions import HasEditPermission, HasSiteEditPermission from profiles.serializers import ( ProfileSerializer, + ProgramLetterSerializer, UserSerializer, UserWebsiteSerializer, ) -from profiles.utils import DEFAULT_PROFILE_IMAGE, generate_svg_avatar +from profiles.utils import ( + DEFAULT_PROFILE_IMAGE, + generate_svg_avatar, + validate_uuid, +) @extend_schema(exclude=True) @@ -36,6 +45,30 @@ class UserViewSet(viewsets.ModelViewSet): lookup_field = "username" +@extend_schema_view( + retrieve=extend_schema( + summary="Retrieve", + description="Retrieve a single program letter.", + responses=ProgramLetterSerializer(), + ), +) +class ProgramLetterViewSet(viewsets.ViewSet): + """Detail only View for program letters""" + + authentication_classes = [] + permission_classes = [] + serializer_class = ProgramLetterSerializer + + def retrieve(self, request, pk=None): # noqa: ARG002 + queryset = ProgramLetter.objects.all() + if not validate_uuid(pk): + invalid_uuid = "Invalid letter uuid" + raise Http404(invalid_uuid) + program_letter = get_object_or_404(queryset, pk=pk) + serializer = ProgramLetterSerializer(program_letter) + return Response(serializer.data) + + class CurrentUserRetrieveViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): """User retrieve and update viewsets for the current user""" @@ -90,3 +123,23 @@ def name_initials_avatar_view( return redirect(DEFAULT_PROFILE_IMAGE) svg = generate_svg_avatar(user.profile.name, int(size), color, bgcolor) return HttpResponse(svg2png(bytestring=svg), content_type="image/png") + + +@method_decorator(login_required, name="dispatch") +class ProgramLetterInterceptView(View): + """ + View that generates a uuid (via ProgramLetter instance) + and then passes the user along to the shareable letter view + """ + + def get(self, request, **kwargs): + program_id = kwargs.get("program_id") + certificate = get_object_or_404( + ProgramCertificate, + user_email=request.user.email, + micromasters_program_id=program_id, + ) + letter, created = ProgramLetter.objects.get_or_create( + user=request.user, certificate=certificate + ) + return HttpResponseRedirect(letter.get_absolute_url()) diff --git a/profiles/views_test.py b/profiles/views_test.py index ceb531cb46..0f204fe24a 100644 --- a/profiles/views_test.py +++ b/profiles/views_test.py @@ -9,6 +9,9 @@ from django.urls import reverse from rest_framework import status +from profiles.factories import ProgramCertificateFactory, ProgramLetterFactory +from profiles.models import ProgramLetter +from profiles.serializers import ProgramLetterSerializer from profiles.utils import DEFAULT_PROFILE_IMAGE, make_temp_image_file pytestmark = [pytest.mark.django_db] @@ -323,3 +326,90 @@ def test_get_user_by_me(mocker, client, user, is_anonymous): "placename": profile.location.get("value", ""), }, } + + +@pytest.mark.parametrize("is_anonymous", [True, False]) +def test_letter_intercept_view_generates_program_letter( + mocker, client, user, is_anonymous +): + """ + Test that the letter intercept view generates a + ProgramLetter and then passes the user along to the display. + Also test that anonymous users do not generate letters and cant access this page + """ + + micromasters_program_id = 1 + if not is_anonymous: + client.force_login(user) + cert = ProgramCertificateFactory( + user_email=user.email, micromasters_program_id=micromasters_program_id + ) + assert ProgramLetter.objects.filter(user=user).count() == 0 + + response = client.get( + reverse("profile:program-letter-intercept", args=[micromasters_program_id]) + ) + assert ProgramLetter.objects.filter(user=user).count() == 1 + letter_id = ProgramLetter.objects.get(user=user, certificate=cert).id + assert response.url == f"/program_letter/{letter_id}/view" + else: + cert = ProgramCertificateFactory( + user_email=user.email, micromasters_program_id=micromasters_program_id + ) + ProgramLetterFactory(user=user, certificate=cert) + response = client.get( + reverse("profile:program-letter-intercept", args=[micromasters_program_id]) + ) + assert response.status_code == 302 + + +@pytest.mark.parametrize("is_anonymous", [True, False]) +def test_program_letter_api_view(mocker, client, user, is_anonymous): + """ + Test that the program letter display page is viewable by + all users logged in or not + """ + mock_return_value = { + "id": 4, + "meta": {}, + "program_letter_footer": "", + "program_letter_logo": {}, + "title": "Supply Chain Management", + "program_id": 1, + "program_letter_footer_text": "", + "program_letter_header_text": "", + "program_letter_text": "

Congratulations

", + "program_letter_signatories": [], + } + mocker.patch( + "profiles.serializers.fetch_program_letter_template_data", + return_value=mock_return_value, + ) + micromasters_program_id = 1 + if not is_anonymous: + client.force_login(user) + cert = ProgramCertificateFactory( + user_email=user.email, micromasters_program_id=micromasters_program_id + ) + program_letter = ProgramLetterFactory(user=user, certificate=cert) + response = client.get( + reverse("lr:v1:program_letters_api-detail", args=[program_letter.id]) + ) + assert response.data == ProgramLetterSerializer(instance=program_letter).data + + +@pytest.mark.parametrize("is_anonymous", [True, False]) +def test_program_letter_api_view_returns_404_for_invalid_id( + mocker, client, user, is_anonymous +): + """ + Test that the program letter api responds with 404 + for malformed uuids + """ + response = client.get( + reverse( + "lr:v1:program_letters_api-detail", + args=["5de96fc0-449e-4668-be89-a119dbdcab799999"], + ) + ) + assert response.status_code == 404 diff --git a/templates/program_letter.html b/templates/program_letter.html new file mode 100644 index 0000000000..b536db83c8 --- /dev/null +++ b/templates/program_letter.html @@ -0,0 +1,154 @@ +{% load i18n %} + + + + + +
+
+
+ {{ header_text | safe }} +
+ +
+
+

+ Dear + {{ name }}, +

+
+ {{ program_letter_text | safe}} +
+
+ {% for signatory in program_letter_signatories %} +
+
+ +
+
+ {{ signatory.name }}, + {{ signatory.title_line_1 }}{% if signatory.title_line_2 %},{{ signatory.title_line_2 }}{% endif %} +
+
+ {% endfor %} +
+
+ +
+ +