diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b3cd48d3b..e11e49722f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Add offer and price fields to courseRun displayed at admin view - Add Additional Information section for a category and use them in a course page - Added new page extension `MainMenuEntry` diff --git a/cookiecutter/{{cookiecutter.organization}}-richie-site-factory/template/{{cookiecutter.site}}/src/backend/{{cookiecutter.site}}/settings.py b/cookiecutter/{{cookiecutter.organization}}-richie-site-factory/template/{{cookiecutter.site}}/src/backend/{{cookiecutter.site}}/settings.py index 518a0fff2b..11466984ca 100644 --- a/cookiecutter/{{cookiecutter.organization}}-richie-site-factory/template/{{cookiecutter.site}}/src/backend/{{cookiecutter.site}}/settings.py +++ b/cookiecutter/{{cookiecutter.organization}}-richie-site-factory/template/{{cookiecutter.site}}/src/backend/{{cookiecutter.site}}/settings.py @@ -332,6 +332,13 @@ class Base(StyleguideMixin, DRFMixin, RichieCoursesConfigurationMixin, Configura environ_prefix=None, ) + # Course run price currency value that would be shown on course detail page + RICHIE_DEFAULT_COURSE_RUN_PRICE_CURRENCY = values.Value( + "EUR", + environ_name="RICHIE_DEFAULT_COURSE_RUN_PRICE_CURRENCY", + environ_prefix=None, + ) + # Internationalization TIME_ZONE = "Europe/Paris" USE_I18N = True diff --git a/src/frontend/js/types/index.ts b/src/frontend/js/types/index.ts index 6dde715eed..b24caff732 100644 --- a/src/frontend/js/types/index.ts +++ b/src/frontend/js/types/index.ts @@ -35,6 +35,11 @@ export interface CourseRun { title?: string; snapshot?: string; display_mode: CourseRunDisplayMode; + price?: number; + price_currency?: string; + offer?: string; + certificate_price?: number; + certificate_offer?: string; } export enum Priority { diff --git a/src/frontend/js/utils/test/factories/richie.ts b/src/frontend/js/utils/test/factories/richie.ts index 824e8f1fef..7afdedb17e 100644 --- a/src/frontend/js/utils/test/factories/richie.ts +++ b/src/frontend/js/utils/test/factories/richie.ts @@ -46,6 +46,18 @@ export const CourseStateFutureOpenFactory = factory(() => { }); export const CourseRunFactory = factory(() => { + let offers = ['PAID', 'FREE', 'PARTIALLY_FREE', 'SUBSCRIPTION']; + const offer = offers[Math.floor(Math.random() * offers.length)]; + offers = ['PAID', 'FREE', 'SUBSCRIPTION']; + const certificateOffer = offers[Math.floor(Math.random() * offers.length)]; + const currency = faker.finance.currency().code; + const price = ['FREE', 'PARTIALLY_FREE'].includes(offer) + ? 0 + : parseFloat(faker.finance.amount({ min: 1, max: 100, symbol: currency, autoFormat: true })); + const cerficatePrice = + certificateOffer === 'FREE' + ? 0 + : parseFloat(faker.finance.amount({ min: 1, max: 100, symbol: currency, autoFormat: true })); return { id: faker.number.int(), resource_link: FactoryHelper.unique(faker.internet.url), @@ -58,6 +70,11 @@ export const CourseRunFactory = factory(() => { dashboard_link: null, title: faker.lorem.sentence(3), display_mode: CourseRunDisplayMode.DETAILED, + price, + price_currency: currency, + offer, + certificate_price: cerficatePrice, + certificate_offer: certificateOffer, }; }); diff --git a/src/frontend/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRun/index.tsx b/src/frontend/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRun/index.tsx index 2f4be83ce5..58e1dd536e 100644 --- a/src/frontend/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRun/index.tsx +++ b/src/frontend/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRun/index.tsx @@ -46,6 +46,51 @@ const messages = defineMessages({ description: 'Course date of an opened course run block', defaultMessage: 'From {startDate} {endDate, select, undefined {} other {to {endDate}}}', }, + coursePrice: { + id: 'components.SyllabusCourseRun.coursePrice', + description: 'Title of the course enrollment price section of an opened course run block', + defaultMessage: 'Enrollment price', + }, + certificationPrice: { + id: 'components.SyllabusCourseRun.certificationPrice', + description: 'Title of the certification price section of an opened course run block', + defaultMessage: 'Certification price', + }, + coursePaidOffer: { + id: 'components.SyllabusCourseRun.coursePaidOffer', + description: 'Message for the paid course offer of an opened course run block', + defaultMessage: 'The course content is paid.', + }, + courseFreeOffer: { + id: 'components.SyllabusCourseRun.courseFreeOffer', + description: 'Message for the free course offer of an opened course run block', + defaultMessage: 'The course content is free.', + }, + coursePartiallyFree: { + id: 'components.SyllabusCourseRun.coursePartiallyFree', + description: 'Message for the partially free course offer of an opened course run block', + defaultMessage: 'The course content is free.', + }, + courseSubscriptionOffer: { + id: 'components.SyllabusCourseRun.courseSubscriptionOffer', + description: 'Message for the subscription course offer of an opened course run block', + defaultMessage: 'Subscribe to access the course content.', + }, + certificatePaidOffer: { + id: 'components.SyllabusCourseRun.certificatePaidOffer', + description: 'Messagge for the paid certification offer of an opened course run block', + defaultMessage: 'The certification process is paid.', + }, + certificateFreeOffer: { + id: 'components.SyllabusCourseRun.certificateFreeOffer', + description: 'Message for the free certification offer of an opened course run block', + defaultMessage: 'The certification process is free.', + }, + certificateSubscriptionOffer: { + id: 'components.SyllabusCourseRun.certificateSubscriptionOffer', + description: 'Message for the subscription certification offer of an opened course run block', + defaultMessage: 'The certification process is offered through subscription.', + }, }); const OpenedCourseRun = ({ @@ -63,6 +108,44 @@ const OpenedCourseRun = ({ const enrollmentEnd = courseRun.enrollment_end ? formatDate(courseRun.enrollment_end) : '...'; const start = courseRun.start ? formatDate(courseRun.start) : '...'; const end = courseRun.end ? formatDate(courseRun.end) : '...'; + let courseOfferMessage = null; + let certificationOfferMessage = null; + let enrollmentPrice = ''; + let certificatePrice = ''; + + if (courseRun.offer) { + const offer = courseRun.offer.toUpperCase().replaceAll(' ', '_'); + courseOfferMessage = { + PAID: messages.coursePaidOffer, + FREE: messages.courseFreeOffer, + PARTIALLY_FREE: messages.coursePartiallyFree, + SUBSCRIPTION: messages.courseSubscriptionOffer, + }[offer]; + + if ((courseRun.price ?? -1) >= 0) { + enrollmentPrice = intl.formatNumber(courseRun.price!, { + style: 'currency', + currency: courseRun.price_currency, + }); + } + } + + if (courseRun.certificate_offer) { + const certificationOffer = courseRun.certificate_offer.toUpperCase().replaceAll(' ', ''); + certificationOfferMessage = { + PAID: messages.certificatePaidOffer, + FREE: messages.certificateFreeOffer, + SUBSCRIPTION: messages.certificateSubscriptionOffer, + }[certificationOffer]; + + if ((courseRun.certificate_price ?? -1) >= 0) { + certificatePrice = intl.formatNumber(courseRun.certificate_price!, { + style: 'currency', + currency: courseRun.price_currency, + }); + } + } + return ( <> {courseRun.title &&

{StringHelper.capitalizeFirst(courseRun.title)}

} @@ -99,6 +182,30 @@ const OpenedCourseRun = ({
{IntlHelper.getLocalizedLanguages(courseRun.languages, intl)}
)} + {courseOfferMessage && ( + <> +
+ +
+
+ +
+ {`${enrollmentPrice}`} +
+ + )} + {certificationOfferMessage && ( + <> +
+ +
+
+ +
+ {`${certificatePrice}`} +
+ + )} {findLmsBackend(courseRun.resource_link) ? ( diff --git a/src/frontend/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRunCompacted/index.tsx b/src/frontend/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRunCompacted/index.tsx index 338ee52a6c..906f7f1d33 100644 --- a/src/frontend/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRunCompacted/index.tsx +++ b/src/frontend/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRunCompacted/index.tsx @@ -41,6 +41,51 @@ const messages = defineMessages({ description: 'Self paced course run block with no end date', defaultMessage: 'Available', }, + coursePrice: { + id: 'components.SyllabusCourseRunCompacted.coursePrice', + description: 'Title of the course enrollment price section of an opened course run block', + defaultMessage: 'Enrollment price', + }, + certificationPrice: { + id: 'components.SyllabusCourseRunCompacted.certificationPrice', + description: 'Title of the certification price section of an opened course run block', + defaultMessage: 'Certification price', + }, + coursePaidOffer: { + id: 'components.SyllabusCourseRunCompacted.coursePaidOffer', + description: 'Message for the paid course offer of an opened course run block', + defaultMessage: 'The course content is paid.', + }, + courseFreeOffer: { + id: 'components.SyllabusCourseRunCompacted.courseFreeOffer', + description: 'Message for the free course offer of an opened course run block', + defaultMessage: 'The course content is free.', + }, + coursePartiallyFree: { + id: 'components.SyllabusCourseRunCompacted.coursePartiallyFree', + description: 'Message for the partially free course offer of an opened course run block', + defaultMessage: 'The course content is free.', + }, + courseSubscriptionOffer: { + id: 'components.SyllabusCourseRunCompacted.courseSubscriptionOffer', + description: 'Message for the subscription course offer of an opened course run block', + defaultMessage: 'Subscribe to access the course content.', + }, + certificatePaidOffer: { + id: 'components.SyllabusCourseRunCompacted.certificatePaidOffer', + description: 'Messagge for the paid certification offer of an opened course run block', + defaultMessage: 'The certification process is paid.', + }, + certificateFreeOffer: { + id: 'components.SyllabusCourseRunCompacted.certificateFreeOffer', + description: 'Message for the free certification offer of an opened course run block', + defaultMessage: 'The certification process is free.', + }, + certificateSubscriptionOffer: { + id: 'components.SyllabusCourseRunCompacted.certificateSubscriptionOffer', + description: 'Message for the subscription certification offer of an opened course run block', + defaultMessage: 'The certification process is offered through subscription.', + }, }); const OpenedSelfPacedCourseRun = ({ @@ -54,6 +99,44 @@ const OpenedSelfPacedCourseRun = ({ const intl = useIntl(); const end = courseRun.end ? formatDate(courseRun.end) : '...'; const hasEndDate = end !== '...'; + let courseOfferMessage = null; + let certificationOfferMessage = null; + let enrollmentPrice = ''; + let certificatePrice = ''; + + if (courseRun.offer) { + const offer = courseRun.offer.toUpperCase().replaceAll(' ', '_'); + courseOfferMessage = { + PAID: messages.coursePaidOffer, + FREE: messages.courseFreeOffer, + PARTIALLY_FREE: messages.coursePartiallyFree, + SUBSCRIPTION: messages.courseSubscriptionOffer, + }[offer]; + + if ((courseRun.price ?? -1) >= 0) { + enrollmentPrice = intl.formatNumber(courseRun.price!, { + style: 'currency', + currency: courseRun.price_currency, + }); + } + } + + if (courseRun.certificate_offer) { + const certificationOffer = courseRun.certificate_offer.toUpperCase().replaceAll(' ', ''); + certificationOfferMessage = { + PAID: messages.certificatePaidOffer, + FREE: messages.certificateFreeOffer, + SUBSCRIPTION: messages.certificateSubscriptionOffer, + }[certificationOffer]; + + if ((courseRun.certificate_price ?? -1) >= 0) { + certificatePrice = intl.formatNumber(courseRun.certificate_price!, { + style: 'currency', + currency: courseRun.price_currency, + }); + } + } + return ( <> {courseRun.title &&

{StringHelper.capitalizeFirst(courseRun.title)}

} @@ -83,6 +166,30 @@ const OpenedSelfPacedCourseRun = ({
{IntlHelper.getLocalizedLanguages(courseRun.languages, intl)}
)} + {courseOfferMessage && ( + <> +
+ +
+
+ +
+ {`${enrollmentPrice}`} +
+ + )} + {certificationOfferMessage && ( + <> +
+ +
+
+ +
+ {`${certificatePrice}`} +
+ + )} {findLmsBackend(courseRun.resource_link) ? ( diff --git a/src/frontend/js/widgets/SyllabusCourseRunsList/index.spec.tsx b/src/frontend/js/widgets/SyllabusCourseRunsList/index.spec.tsx index 3f6703c03f..5f4eb3d6b7 100644 --- a/src/frontend/js/widgets/SyllabusCourseRunsList/index.spec.tsx +++ b/src/frontend/js/widgets/SyllabusCourseRunsList/index.spec.tsx @@ -30,6 +30,8 @@ import { computeStates } from 'utils/CourseRuns'; import { IntlHelper } from 'utils/IntlHelper'; import { render } from 'utils/test/render'; import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper'; +import { SyllabusCourseRunCompacted } from './components/SyllabusCourseRunCompacted'; +import { SyllabusCourseRun } from './components/SyllabusCourseRun'; jest.mock('utils/context', () => { const mock = mockRichieContextFactory().one(); @@ -127,7 +129,6 @@ describe('', () => { const languagesContainer = languagesNode.nextSibling! as HTMLElement; getByText(languagesContainer, IntlHelper.getLocalizedLanguages(courseRun.languages, intl)); - expect(languagesContainer.nextSibling).toBeNull(); getByRole(runContainer, 'link', { name: StringHelper.capitalizeFirst(courseRun.state.call_to_action)!, }); @@ -568,7 +569,7 @@ describe('', () => { const portalContainer = getPortalContainer(); - // Expect that "is-hidden" class is set only to course runs in (MAX_ARCHIVED_COURSE_RUNS)nth-plus position. + // Expect that 'is-hidden' class is set only to course runs in (MAX_ARCHIVED_COURSE_RUNS)nth-plus position. expect(portalContainer.querySelectorAll('li').length).toBe(MAX_ARCHIVED_COURSE_RUNS * 2); portalContainer.querySelectorAll('li').forEach((listElement, i) => { expectCourseRunInList(listElement, courseRuns[i]); @@ -585,7 +586,7 @@ describe('', () => { // click on view more. await act(async () => user.click(button)); - // expect that "is-hidden" are removed. + // expect that 'is-hidden' are removed. portalContainer.querySelectorAll('li').forEach((listElement, i) => { expectCourseRunInList(listElement, courseRuns[i]); expect(listElement.classList).not.toContain('is-hidden'); @@ -615,7 +616,7 @@ describe('', () => { const portalContainer = getPortalContainer(); expect(screen.queryByRole('button', { name: 'View more' })).not.toBeInTheDocument(); - // expect that "is-hidden" is not set. + // expect that 'is-hidden' is not set. expect(portalContainer.querySelectorAll('li').length).toBe(MAX_ARCHIVED_COURSE_RUNS - 1); portalContainer.querySelectorAll('li').forEach((listElement, i) => { expectCourseRunInList(listElement, courseRuns[i]); @@ -1009,7 +1010,7 @@ describe('', () => { const listElements = screen.getAllByRole('listitem'); expect(listElements.length).toBe(4); - // Assert there is only one link, one label "Enrolled" and one request to retrieve enrollment. + // Assert there is only one link, one label 'Enrolled' and one request to retrieve enrollment. expect(await screen.findAllByText('Enrolled')).toHaveLength(1); const links = screen.getAllByRole('link'); @@ -1024,4 +1025,469 @@ describe('', () => { `https://demo.endpoint/api/enrollment/v1/enrollment/${user.username},${onGoingCourseRun.resource_link}`, ); }); + + it('renders price information as paid and paid on SyllabusCourseRunCompacted', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'paid'; + courseRun.certificate_offer = 'paid'; + courseRun.price_currency = 'EUR'; + courseRun.price = 49.99; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is paid.
€49.99
'); + expect(content).toContain('
The certification process is paid.
€59.99
'); + }); + + it('renders price information as subscription on SyllabusCourseRunCompacted', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'Subscription'; + courseRun.certificate_offer = 'Subscription'; + courseRun.price_currency = 'EUR'; + courseRun.price = 49.99; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
Subscribe to access the course content.
€49.99
'); + expect(content).toContain( + '
The certification process is offered through subscription.
€59.99
', + ); + }); + + it('renders price information as Partially free on SyllabusCourseRunCompacted', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'Partially free'; + courseRun.certificate_offer = 'paid'; + courseRun.price_currency = 'EUR'; + courseRun.price = 0; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is free.
€0.00
'); + expect(content).toContain('
The certification process is paid.
€59.99
'); + }); + + it('renders price information as paid and free on SyllabusCourseRunCompacted', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'paid'; + courseRun.certificate_offer = 'free'; + courseRun.price_currency = 'EUR'; + courseRun.price = 49.99; + courseRun.certificate_price = 0; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is paid.
€49.99
'); + expect(content).toContain('
The certification process is free.
€0.00
'); + }); + + it('does not render price information on SyllabusCourseRunCompacted', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = undefined; + courseRun.certificate_offer = undefined; + courseRun.price = 59.99; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).not.toContain('The course content is paid'); + expect(content).not.toContain('The certification process is paid.'); + }); + + it('does not render course price information on SyllabusCourseRunCompacted', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.certificate_offer = 'paid'; + courseRun.price_currency = 'EUR'; + courseRun.offer = undefined; + courseRun.price = 59.99; + courseRun.certificate_offer = 'paid'; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).not.toContain('The course content is paid.'); + expect(content).toContain('
The certification process is paid.
€59.99
'); + }); + + it('does not render certificate price information on SyllabusCourseRunCompacted', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.price_currency = 'EUR'; + courseRun.offer = 'paid'; + courseRun.price = 49.99; + courseRun.certificate_offer = undefined; + courseRun.certificate_price = undefined; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is paid.
€49.99
'); + expect(content).not.toContain('The certification process is paid.'); + }); + + it('does not render prices but only offers on SyllabusCourseRunCompacted', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'free'; + courseRun.certificate_offer = 'free'; + courseRun.price_currency = 'EUR'; + courseRun.price = undefined; + courseRun.certificate_price = undefined; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is free.
'); + expect(content).toContain('
The certification process is free.
'); + }); + + it('renders prices as zero on SyllabusCourseRunCompacted', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'free'; + courseRun.certificate_offer = 'free'; + courseRun.price_currency = 'EUR'; + courseRun.price = 0; + courseRun.certificate_price = 0; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is free.
€0.00
'); + expect(content).toContain('
The certification process is free.
€0.00
'); + }); + + it('does not render invalid offers on SyllabusCourseRunCompacted', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'invalid'; + courseRun.certificate_offer = 'invalid'; + courseRun.price_currency = 'EUR'; + courseRun.price = 59.99; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).not.toContain('The course content is'); + expect(content).not.toContain('The certification process is'); + expect(content).not.toContain('
€59.99'); + }); + + it('renders price information as paid and paid on SyllabusCourseRun', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'paid'; + courseRun.certificate_offer = 'paid'; + courseRun.price_currency = 'EUR'; + courseRun.price = 49.99; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is paid.
€49.99
'); + expect(content).toContain('
The certification process is paid.
€59.99
'); + }); + + it('renders price information as subscription on SyllabusCourseRun', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'Subscription'; + courseRun.certificate_offer = 'Subscription'; + courseRun.price_currency = 'EUR'; + courseRun.price = 49.99; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
Subscribe to access the course content.
€49.99
'); + expect(content).toContain( + '
The certification process is offered through subscription.
€59.99
', + ); + }); + + it('renders price information as Partially free on SyllabusCourseRun', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'Partially free'; + courseRun.certificate_offer = 'paid'; + courseRun.price_currency = 'EUR'; + courseRun.price = 0; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is free.
€0.00
'); + expect(content).toContain('
The certification process is paid.
€59.99
'); + }); + + it('renders price information as paid and free on SyllabusCourseRun', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'paid'; + courseRun.certificate_offer = 'free'; + courseRun.price_currency = 'EUR'; + courseRun.price = 49.99; + courseRun.certificate_price = 0; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is paid.
€49.99
'); + expect(content).toContain('
The certification process is free.
€0.00
'); + }); + + it('does not render price information on SyllabusCourseRun', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = undefined; + courseRun.certificate_offer = undefined; + courseRun.price = 59.99; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).not.toContain('The course content is paid'); + expect(content).not.toContain('The certification process is paid.'); + }); + + it('does not render course price information on SyllabusCourseRun', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = undefined; + courseRun.price = 59.99; + courseRun.price_currency = 'EUR'; + courseRun.certificate_offer = 'paid'; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).not.toContain('The course content is paid.'); + expect(content).toContain('
The certification process is paid.
€59.99
'); + }); + + it('does not render certificate price information on SyllabusCourseRun', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.price_currency = 'EUR'; + courseRun.offer = 'paid'; + courseRun.price = 49.99; + courseRun.certificate_offer = undefined; + courseRun.certificate_price = undefined; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is paid.
€49.99
'); + expect(content).not.toContain('The certification process is paid.'); + }); + + it('does not render prices but only offers on SyllabusCourseRun', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'free'; + courseRun.certificate_offer = 'free'; + courseRun.price_currency = 'EUR'; + courseRun.price = undefined; + courseRun.certificate_price = undefined; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is free.
'); + expect(content).toContain('
The certification process is free.
'); + }); + + it('renders prices as zero on SyllabusCourseRun', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'free'; + courseRun.certificate_offer = 'free'; + courseRun.price_currency = 'EUR'; + courseRun.price = 0; + courseRun.certificate_price = 0; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).toContain('
The course content is free.
€0.00
'); + expect(content).toContain('
The certification process is free.
€0.00
'); + }); + + it('does not render invalid offers on SyllabusCourseRun', async () => { + const course = PacedCourseFactory().one(); + const courseRun: CourseRun = CourseRunFactoryFromPriority(Priority.ONGOING_OPEN)({ + languages: ['en'], + }).one(); + + courseRun.offer = 'invalid'; + courseRun.certificate_offer = 'invalid'; + courseRun.price_currency = 'EUR'; + courseRun.price = 59.99; + courseRun.certificate_price = 59.99; + + render( +
+ +
, + ); + + const content = getHeaderContainer().innerHTML; + expect(content).not.toContain('The course content is'); + expect(content).not.toContain('The certification process is'); + expect(content).not.toContain('
€59.99'); + }); }); diff --git a/src/richie/apps/courses/admin.py b/src/richie/apps/courses/admin.py index 4b7c3029ba..4340291c7d 100644 --- a/src/richie/apps/courses/admin.py +++ b/src/richie/apps/courses/admin.py @@ -57,6 +57,11 @@ class Meta: "languages", "enrollment_count", "catalog_visibility", + "price_currency", + "offer", + "price", + "certificate_offer", + "certificate_price", "sync_mode", "display_mode", ] @@ -150,6 +155,11 @@ class CourseRunAdmin(FrontendEditableAdminMixin, TranslatableAdmin): "languages", "enrollment_count", "catalog_visibility", + "price_currency", + "offer", + "price", + "certificate_offer", + "certificate_price", "sync_mode", ) list_display = ["id"] diff --git a/src/richie/apps/courses/factories.py b/src/richie/apps/courses/factories.py index d428151442..fb89ccc013 100644 --- a/src/richie/apps/courses/factories.py +++ b/src/richie/apps/courses/factories.py @@ -13,6 +13,7 @@ import factory from cms.api import add_plugin +from richie.apps.courses.models.course import CertificateOffer, CourseRunOffer from richie.plugins.nesteditem.defaults import ACCORDION from ..core.defaults import ALL_LANGUAGES @@ -441,6 +442,13 @@ class Meta: resource_link = factory.Faker("uri") sync_mode = models.CourseRunSyncMode.SYNC_TO_PUBLIC display_mode = models.CourseRunDisplayMode.DETAILED + price_currency = "EUR" + price = factory.Faker( + "pydecimal", min_value=1, max_value=100, left_digits=5, right_digits=2 + ) + certificate_price = factory.Faker( + "pydecimal", min_value=1, max_value=100, left_digits=5, right_digits=2 + ) # pylint: disable=no-self-use @factory.lazy_attribute @@ -529,6 +537,24 @@ def enrollment_count(self): """ return random.randint(0, 10000) # nosec + @factory.lazy_attribute + def offer(self): + """ + The offer of a course run is read from Django settings. + """ + return CourseRunOffer.FREE if self.price == 0.0 else CourseRunOffer.PAID + + @factory.lazy_attribute + def certificate_offer(self): + """ + The offer of a course run is read from Django settings. + """ + return ( + CertificateOffer.FREE + if self.certificate_price == 0.0 + else CourseRunOffer.PAID + ) + class CategoryFactory(BLDPageExtensionDjangoModelFactory): """ diff --git a/src/richie/apps/courses/migrations/0036_courserun_certificate_offer_and_more.py b/src/richie/apps/courses/migrations/0036_courserun_certificate_offer_and_more.py new file mode 100644 index 0000000000..8a55abbd8b --- /dev/null +++ b/src/richie/apps/courses/migrations/0036_courserun_certificate_offer_and_more.py @@ -0,0 +1,83 @@ +# Generated by Django 4.2.17 on 2024-12-06 11:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cms", "0022_auto_20180620_1551"), + ("courses", "0035_add_menuentry"), + ] + + operations = [ + migrations.AddField( + model_name="courserun", + name="certificate_offer", + field=models.CharField( + blank=True, + choices=[ + ("free", "free - The certification can be completed without cost"), + ( + "subscription", + "subscription - Must be a subscriber or paid member to carry out the certification process", + ), + ("paid", "paid - Must pay to carry out the certification process"), + ], + max_length=20, + null=True, + verbose_name="certificate offer", + ), + ), + migrations.AddField( + model_name="courserun", + name="certificate_price", + field=models.DecimalField( + blank=True, + decimal_places=2, + help_text="The price of the certificate", + max_digits=9, + null=True, + verbose_name="certificate price", + ), + ), + migrations.AddField( + model_name="courserun", + name="offer", + field=models.CharField( + blank=True, + choices=[ + ("free", "free - The entire course can be completed without cost"), + ( + "partially_free", + "partially_free - More than half of the course is for free", + ), + ( + "subscription", + "subscription - Must be a subscriber or paid member to complete the entire course", + ), + ("paid", "paid - Must pay to complete the course"), + ], + max_length=20, + null=True, + verbose_name="offer", + ), + ), + migrations.AddField( + model_name="courserun", + name="price", + field=models.DecimalField( + blank=True, + decimal_places=2, + help_text="The price of the course run", + max_digits=9, + null=True, + verbose_name="price", + ), + ), + migrations.AddField( + model_name="courserun", + name="price_currency", + field=models.CharField(default="EUR", max_length=7), + ), + ] diff --git a/src/richie/apps/courses/models/course.py b/src/richie/apps/courses/models/course.py index ddb491bbe2..11e4258357 100644 --- a/src/richie/apps/courses/models/course.py +++ b/src/richie/apps/courses/models/course.py @@ -706,6 +706,29 @@ class CourseRunCatalogVisibility(models.TextChoices): HIDDEN = "hidden", _("hidden - hide on the course page and from search results") +class CourseRunOffer(models.TextChoices): + """Course run offer choices.""" + + FREE = "free", _("free - The entire course can be completed without cost") + PARTIALLY_FREE = "partially_free", _( + "partially_free - More than half of the course is for free" + ) + SUBSCRIPTION = "subscription", _( + "subscription - Must be a subscriber or paid member to complete the entire course" + ) + PAID = "paid", _("paid - Must pay to complete the course") + + +class CertificateOffer(models.TextChoices): + """Course run offer choices.""" + + FREE = "free", _("free - The certification can be completed without cost") + SUBSCRIPTION = "subscription", _( + "subscription - Must be a subscriber or paid member to carry out the certification process" + ) + PAID = "paid", _("paid - Must pay to carry out the certification process") + + class CourseRunDisplayMode(models.TextChoices): """Course run catalog display modes.""" @@ -742,7 +765,6 @@ class CourseRun(TranslatableModel): default=CourseRunSyncMode.MANUAL, verbose_name=_("Synchronization mode"), ) - title = TranslatedField() resource_link = models.CharField( _("resource link"), max_length=200, blank=True, null=True @@ -775,6 +797,40 @@ class CourseRun(TranslatableModel): blank=False, max_length=20, ) + price = models.DecimalField( + _("price"), + max_digits=9, + decimal_places=2, + null=True, + blank=True, + help_text=_("The price of the course run"), + ) + price_currency = models.CharField( + max_length=7, + default=getattr(settings, "RICHIE_DEFAULT_COURSE_RUN_PRICE_CURRENCY", "EUR"), + ) + offer = models.CharField( + _("offer"), + choices=lazy(lambda: CourseRunOffer.choices, tuple)(), + blank=True, + null=True, + max_length=20, + ) + certificate_price = models.DecimalField( + _("certificate price"), + max_digits=9, + decimal_places=2, + null=True, + blank=True, + help_text=_("The price of the certificate"), + ) + certificate_offer = models.CharField( + _("certificate offer"), + choices=lazy(lambda: CertificateOffer.choices, tuple)(), + blank=True, + null=True, + max_length=20, + ) display_mode = models.CharField( choices=CourseRunDisplayMode.choices, default=CourseRunDisplayMode.DETAILED, diff --git a/src/richie/apps/courses/serializers.py b/src/richie/apps/courses/serializers.py index fc78811999..994de97c86 100644 --- a/src/richie/apps/courses/serializers.py +++ b/src/richie/apps/courses/serializers.py @@ -41,6 +41,11 @@ class Meta: "catalog_visibility", "display_mode", "snapshot", + "price", + "price_currency", + "offer", + "certificate_price", + "certificate_offer", ] def get_snapshot(self, course_run): @@ -75,6 +80,11 @@ class Meta: "state", "enrollment_count", "catalog_visibility", + "price", + "price_currency", + "offer", + "certificate_price", + "certificate_offer", ] @@ -96,5 +106,10 @@ class Meta: "languages", "enrollment_count", "catalog_visibility", + "price", + "price_currency", + "offer", + "certificate_price", + "certificate_offer", ] extra_kwargs = {"resource_link": {"required": True}} diff --git a/tests/apps/courses/test_admin_course_run.py b/tests/apps/courses/test_admin_course_run.py index 081c08021f..6c2dc17cd7 100644 --- a/tests/apps/courses/test_admin_course_run.py +++ b/tests/apps/courses/test_admin_course_run.py @@ -346,6 +346,11 @@ def _prepare_add_view_post(self, course, status_code): "enrollment_end_0": "2015-01-23", "enrollment_end_1": "09:07:11", "catalog_visibility": "course_and_search", + "price_currency": "EUR", + "offer": "paid", + "price": 59.98, + "certificate_offer": "paid", + "certificate_price": 59.98, "sync_mode": "manual", "display_mode": "detailed", } @@ -434,6 +439,7 @@ def test_admin_course_run_add_view_post_staff_user_page_permission(self): def _prepare_change_view_post(self, course_run, course, status_code, check_method): """Helper method to test the change view.""" + url = reverse("admin:courses_courserun_change", args=[course_run.id]) data = { "direct_course": course.id, @@ -450,6 +456,11 @@ def _prepare_change_view_post(self, course_run, course, status_code, check_metho "enrollment_end_1": "09:07:11", "enrollment_count": "5", "catalog_visibility": "course_and_search", + "price_currency": "EUR", + "offer": "paid", + "price": "59.98", + "certificate_offer": "paid", + "certificate_price": "29.98", "sync_mode": "manual", "display_mode": "detailed", } @@ -479,13 +490,21 @@ def _prepare_change_view_post(self, course_run, course, status_code, check_metho ) check_method(course_run.enrollment_count, 5) check_method(course_run.sync_mode, "manual") + check_method(course_run.offer, "paid") + check_method(float(course_run.price), 59.98) + check_method(course_run.certificate_offer, "paid") + check_method(float(course_run.certificate_price), 29.98) + return response def test_admin_course_run_change_view_post_anonymous(self): """ Anonymous users should not be allowed to update course runs via the admin. """ - course_run = CourseRunFactory() + course_run = CourseRunFactory( + offer="free", + certificate_offer="free", + ) snapshot = CourseFactory(page_parent=course_run.direct_course.extended_object) response = self._prepare_change_view_post( @@ -499,7 +518,10 @@ def test_admin_course_run_change_view_post_superuser_draft(self): """ Validate that the draft course run can be updated via the admin. """ - course_run = CourseRunFactory() + course_run = CourseRunFactory( + offer="paid", + certificate_offer="paid", + ) snapshot = CourseFactory(page_parent=course_run.direct_course.extended_object) user = UserFactory(is_staff=True, is_superuser=True) @@ -511,7 +533,10 @@ def test_admin_course_run_change_view_post_superuser_public(self): """ Validate that the public course run can not be updated via the admin. """ - course_run = CourseRunFactory() + course_run = CourseRunFactory( + offer="free", + certificate_offer="free", + ) snapshot = CourseFactory(page_parent=course_run.direct_course.extended_object) course_run.direct_course.extended_object.publish("en") course_run.refresh_from_db() @@ -528,7 +553,10 @@ def test_admin_course_run_change_view_post_staff_user_missing_permission(self): Staff users with missing page permissions can not update a course run via the admin unless CMS permissions are not activated. """ - course_run = CourseRunFactory() + course_run = CourseRunFactory( + offer="free", + certificate_offer="free", + ) snapshot = CourseFactory(page_parent=course_run.direct_course.extended_object) user = UserFactory(is_staff=True) diff --git a/tests/apps/courses/test_admin_form_course_run.py b/tests/apps/courses/test_admin_form_course_run.py index d927c4e34b..75817d323c 100644 --- a/tests/apps/courses/test_admin_form_course_run.py +++ b/tests/apps/courses/test_admin_form_course_run.py @@ -36,9 +36,15 @@ def _get_admin_form(course, user): "enrollment_end_0": "2015-01-23", "enrollment_end_1": "09:07:11", "catalog_visibility": "course_and_search", + "price_currency": "EUR", + "offer": "free", + "price": 0.0, + "certificate_offer": "free", + "certificate_price": 0.0, "sync_mode": "manual", "display_mode": "detailed", } + request = RequestFactory().get("/") request.user = user CourseRunAdminForm.request = request @@ -110,16 +116,15 @@ def test_admin_form_course_run_superuser_empty_form(self): form = CourseRunAdminForm(data={"resource_link": "https://example.com"}) self.assertFalse(form.is_valid()) - self.assertEqual( - form.errors, - { - "direct_course": ["This field is required."], - "display_mode": ["This field is required."], - "languages": ["This field is required."], - "catalog_visibility": ["This field is required."], - "sync_mode": ["This field is required."], - }, - ) + + for field in [ + "direct_course", + "display_mode", + "languages", + "catalog_visibility", + "sync_mode", + ]: + self.assertEqual(form.errors[field], ["This field is required."]) # Direct course choices diff --git a/tests/apps/courses/test_api_course_run_sync.py b/tests/apps/courses/test_api_course_run_sync.py index 4079c8cddf..b8cbcf883c 100644 --- a/tests/apps/courses/test_api_course_run_sync.py +++ b/tests/apps/courses/test_api_course_run_sync.py @@ -21,7 +21,19 @@ # pylint: disable=too-many-public-methods @mock.patch.object(post_publish, "send", wraps=post_publish.send) -@override_settings(RICHIE_COURSE_RUN_SYNC_SECRETS=["shared secret"]) +@override_settings( + RICHIE_COURSE_RUN_SYNC_SECRETS=["shared secret"], + RICHIE_LMS_BACKENDS=[ + { + "BASE_URL": "http://localhost:8073", + "BACKEND": "richie.apps.courses.lms.edx.EdXLMSBackend", + "COURSE_RUN_SYNC_NO_UPDATE_FIELDS": [], + "COURSE_REGEX": r"^.*/courses/(?P.*)/course/?$", + "JS_BACKEND": "dummy", + "JS_COURSE_REGEX": r"^.*/courses/(?.*)/course/?$", + } + ], +) class SyncCourseRunApiTestCase(CMSTestCase): """Test calls to sync a course run via API endpoint.""" @@ -200,6 +212,11 @@ def test_api_course_run_sync_create_sync_to_public_draft_course(self, mock_signa "languages": ["en", "fr"], "enrollment_count": 46782, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", } self.assertEqual( @@ -210,7 +227,7 @@ def test_api_course_run_sync_create_sync_to_public_draft_course(self, mock_signa authorization = ( "SIG-HMAC-SHA256 " - "5bdfb326b35fccaef9961e03cf617c359c86ffbb6c64e0f7e074aa011e8af9d6" + "8c70578af0fd658c5d2c55b1e47f1266060a1897e8fb15a690e25d7af0061c6e" ) response = self.client.post( "/api/v1.0/course-runs-sync", @@ -255,6 +272,11 @@ def test_api_course_run_sync_create_sync_to_public_published_course( "languages": ["en", "fr"], "enrollment_count": 324, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", } self.assertEqual( course.extended_object.title_set.first().publisher_state, @@ -267,7 +289,7 @@ def test_api_course_run_sync_create_sync_to_public_published_course( data, content_type="application/json", HTTP_AUTHORIZATION=( - "SIG-HMAC-SHA256 8e232f3a6071a10cded2740bdc71aed06aa637719d28f968c7b7d35eccd765f7" + "SIG-HMAC-SHA256 41ee852433729021123b84d25a0c6124b5a3ecab6a1739bce0709325bfa54c77" ), ) @@ -312,6 +334,11 @@ def test_api_course_run_sync_create_sync_to_draft(self, mock_signal): "languages": ["en", "fr"], "enrollment_count": 47892, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", } self.assertEqual( course.extended_object.title_set.first().publisher_state, @@ -324,7 +351,7 @@ def test_api_course_run_sync_create_sync_to_draft(self, mock_signal): data, content_type="application/json", HTTP_AUTHORIZATION=( - "SIG-HMAC-SHA256 bb453816becb5df16949b915dd577a2cabf734e4429bfbd3bdb727bde39c58b7" + "SIG-HMAC-SHA256 06c4ac99d2c51bfd395247564fdca4bce30eab7fb7e5abfe5a1e6250172bbf8f" ), ) @@ -392,6 +419,11 @@ def test_api_course_run_sync_create_partial_not_required(self, mock_signal): "languages": ["en", "fr"], "enrollment_count": 45, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", } self.assertEqual( @@ -405,7 +437,7 @@ def test_api_course_run_sync_create_partial_not_required(self, mock_signal): data, content_type="application/json", HTTP_AUTHORIZATION=( - "SIG-HMAC-SHA256 723d3312759b6755bc8bbe05a9c2c719d2b4a3bdf381e2036a93119bf192aeda" + "SIG-HMAC-SHA256 7bcb01fbbaac9d5bb2189d1dda76f77dbc4776e471f8660f11b47c75ac41e530" ), ) @@ -536,7 +568,10 @@ def test_api_course_run_sync_existing_draft_manual(self, mock_signal): ) self.assertFalse(mock_signal.called) - @override_settings(TIME_ZONE="UTC") + @override_settings( + RICHIE_DEFAULT_COURSE_RUN_SYNC_MODE="sync_to_public", + TIME_ZONE="UTC", + ) def test_api_course_run_sync_existing_published_sync_to_public(self, mock_signal): """ If a course run exists in "sync_to_public" mode (draft and public versions), @@ -565,6 +600,11 @@ def test_api_course_run_sync_existing_published_sync_to_public(self, mock_signal "languages": ["en", "fr"], "enrollment_count": 15682, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", } response = self.client.post( @@ -572,7 +612,7 @@ def test_api_course_run_sync_existing_published_sync_to_public(self, mock_signal data, content_type="application/json", HTTP_AUTHORIZATION=( - "SIG-HMAC-SHA256 25de22f3674a207a2bd3923dcc5e302a21c9aac8eee7c835f084349da69d0472" + "SIG-HMAC-SHA256 380334d89f9937a4e4d5a396bdf11dae92aad007c19584b6a30fe7d16dec7f0e" ), ) @@ -627,6 +667,11 @@ def test_api_course_run_sync_existing_draft_sync_to_public(self, mock_signal): "languages": ["en", "fr"], "enrollment_count": 2042, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", } response = self.client.post( @@ -634,7 +679,7 @@ def test_api_course_run_sync_existing_draft_sync_to_public(self, mock_signal): data, content_type="application/json", HTTP_AUTHORIZATION=( - "SIG-HMAC-SHA256 6f85261b995a8ca78b5610cfe47fd6a0e321f26c671b606d12225bbea72fc8f0" + "SIG-HMAC-SHA256 6498774003a35f135813cd6189e4d3661f9552dc46127734bf5def851caf1a03" ), ) @@ -683,6 +728,11 @@ def test_api_course_run_sync_existing_draft_with_public_course_sync_to_public( "languages": ["en", "fr"], "enrollment_count": 103123, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", } response = self.client.post( @@ -690,7 +740,7 @@ def test_api_course_run_sync_existing_draft_with_public_course_sync_to_public( data, content_type="application/json", HTTP_AUTHORIZATION=( - "SIG-HMAC-SHA256 262963565518c85901059500b274568a4d5583d507c375604e9845083d5d7095" + "SIG-HMAC-SHA256 22ea488f43f4ff56eedbc16aa49acc63365185c082ac6357187f0d6bca5c4d75" ), ) @@ -747,6 +797,11 @@ def test_api_course_run_sync_existing_published_sync_to_draft(self, mock_signal) "languages": ["en", "fr"], "enrollment_count": 542, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", } response = self.client.post( @@ -754,7 +809,7 @@ def test_api_course_run_sync_existing_published_sync_to_draft(self, mock_signal) data, content_type="application/json", HTTP_AUTHORIZATION=( - "SIG-HMAC-SHA256 db30268ee706fd147c6f04567faa88ed84fd06f08dbc944fff6c0a4973b06599" + "SIG-HMAC-SHA256 87245828104eec056bc7c8f6faf9183576bf4e7130202ada838b0035614e431b" ), ) @@ -806,6 +861,11 @@ def test_api_course_run_sync_existing_draft_sync_to_draft(self, mock_signal): "languages": ["en", "fr"], "enrollment_count": 986, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", } response = self.client.post( @@ -813,7 +873,7 @@ def test_api_course_run_sync_existing_draft_sync_to_draft(self, mock_signal): data, content_type="application/json", HTTP_AUTHORIZATION=( - "SIG-HMAC-SHA256 3bed4a7b1595f49957bd949ed0192d5f1416d4f6a1c409fc8b03b1a1ebad0f39" + "SIG-HMAC-SHA256 be70df8c3b07c60be7b3d4551ac385c5cd6fd5e994e4eac4cb92f83ae05dabf3" ), ) @@ -923,6 +983,11 @@ def test_api_course_run_sync_update_with_no_update_fields(self, mock_signal): "languages": ["en", "fr"], "enrollment_count": 12345, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", } response = self.client.post( @@ -930,7 +995,7 @@ def test_api_course_run_sync_update_with_no_update_fields(self, mock_signal): data, content_type="application/json", HTTP_AUTHORIZATION=( - "SIG-HMAC-SHA256 13433eb9159326b7d0f38ea86ab1ef8510ac4bc643d997d2ad01e349bee15570" + "SIG-HMAC-SHA256 12b922f6065af2c46c8eda92bc9e3842cbf001e921356cedc30afcdb3566687d" ), ) self.assertEqual(response.status_code, 200) @@ -1037,6 +1102,11 @@ def test_api_course_run_sync_create_bulk_success(self, mock_signal): "languages": ["en", "fr"], "enrollment_count": 46782, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", }, { "resource_link": resource_link2, @@ -1047,6 +1117,11 @@ def test_api_course_run_sync_create_bulk_success(self, mock_signal): "languages": ["en"], "enrollment_count": 210, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", }, ] @@ -1054,7 +1129,7 @@ def test_api_course_run_sync_create_bulk_success(self, mock_signal): authorization = ( "SIG-HMAC-SHA256 " - "3f23b25632caa04b5fb9ac8b21f5143779fb61b6fa9b0422fce0f6fdad0b3de3" + "6b62d01d2d7708fc8757bdc52b1e6bbc11ab93503a7238f8e69ec28b20f60ab6" ) response = self.client.post( "/api/v1.0/course-runs-sync", @@ -1133,9 +1208,7 @@ def test_api_course_run_sync_create_bulk_missing_resource_link(self, _mock_signa ) self.assertFalse(CourseRun.objects.exists()) - @override_settings( - RICHIE_DEFAULT_COURSE_RUN_SYNC_MODE="sync_to_public", TIME_ZONE="UTC" - ) + @override_settings(TIME_ZONE="UTC") def test_api_course_run_sync_create_bulk_errors(self, mock_signal): """ When errors occur on one of the course runs in bulk. The error is included @@ -1158,6 +1231,11 @@ def test_api_course_run_sync_create_bulk_errors(self, mock_signal): "languages": ["en", "fr"], "enrollment_count": 46782, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", }, { "resource_link": resource_link2, @@ -1168,6 +1246,11 @@ def test_api_course_run_sync_create_bulk_errors(self, mock_signal): "languages": ["en"], "enrollment_count": 210, "catalog_visibility": "course_and_search", + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "19.99", + "certificate_offer": "paid", }, ] @@ -1175,7 +1258,7 @@ def test_api_course_run_sync_create_bulk_errors(self, mock_signal): authorization = ( "SIG-HMAC-SHA256 " - "26339b1ef2d8203b097345e3176ebe857645768c1a65877805c4c30d70ae4495" + "6443aa9d7549a40eaa190c06317fa74354ce525659089aaeb3a8354ed325e27e" ) response = self.client.post( "/api/v1.0/course-runs-sync", @@ -1339,3 +1422,73 @@ def test_api_course_with_parent_courses_page(self, mock_signal): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"success": True}) + + @override_settings( + RICHIE_DEFAULT_COURSE_RUN_SYNC_MODE="sync_to_public", + TIME_ZONE="UTC", + ) + def test_api_course_run_sync_price_info_as_optional(self, mock_signal): + """ + Ensure the price information as optional + """ + + link = "http://example.edx:8073/courses/course-v1:edX+DemoX+01/course/" + course = CourseFactory(code="DemoX") + course.extended_object.publish("en") + course.refresh_from_db() + + self.assertEqual( + course.extended_object.title_set.first().publisher_state, + PUBLISHER_STATE_DEFAULT, + ) + mock_signal.reset_mock() + + data = { + "resource_link": link, + "start": "2020-12-09T09:31:59.417817Z", + "end": "2021-03-14T09:31:59.417895Z", + "enrollment_start": "2020-11-09T09:31:59.417936Z", + "enrollment_end": "2020-12-24T09:31:59.417972Z", + "languages": ["en", "fr"], + "enrollment_count": 15682, + "catalog_visibility": "course_and_search", + } + + response = self.client.post( + "/api/v1.0/course-runs-sync", + data, + content_type="application/json", + HTTP_AUTHORIZATION=( + "SIG-HMAC-SHA256 25de22f3674a207a2bd3923dcc5e302a21c9aac8eee7c835f084349da69d0472" + ), + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"success": True}) + self.assertEqual(CourseRun.objects.count(), 2) + + draft_course_run = CourseRun.objects.get(direct_course=course) + draft_serializer = SyncCourseRunSerializer(instance=draft_course_run) + + self.assertEqual(draft_serializer.data["price_currency"], "EUR") + self.assertEqual(draft_serializer.data["price"], None) + self.assertEqual(draft_serializer.data["offer"], None) + self.assertEqual(draft_serializer.data["certificate_price"], None) + self.assertEqual(draft_serializer.data["certificate_offer"], None) + + public_course_run = CourseRun.objects.get(direct_course=course.public_extension) + public_serializer = SyncCourseRunSerializer(instance=public_course_run) + + self.assertEqual(public_serializer.data["price_currency"], "EUR") + self.assertEqual(public_serializer.data["price"], None) + self.assertEqual(public_serializer.data["offer"], None) + self.assertEqual(public_serializer.data["certificate_price"], None) + self.assertEqual(public_serializer.data["certificate_offer"], None) + + self.assertEqual( + course.extended_object.title_set.first().publisher_state, + PUBLISHER_STATE_DEFAULT, + ) + mock_signal.assert_called_once_with( + sender=Page, instance=course.extended_object, language=None + ) diff --git a/tests/apps/courses/test_models_course_run.py b/tests/apps/courses/test_models_course_run.py index 29f772f2a0..f311b0abbc 100644 --- a/tests/apps/courses/test_models_course_run.py +++ b/tests/apps/courses/test_models_course_run.py @@ -627,6 +627,10 @@ def test_models_course_run_mark_dirty_any_field(self): ) # New random values to update our course run for field in fields: + if field in ["price_currency", "offer", "certificate_offer"]: + # Skip these fields since they are not mandatory fields to be changed + continue + course_run = CourseRunFactory() self.assertTrue(course_run.direct_course.extended_object.publish("en")) title_obj = course_run.direct_course.extended_object.title_set.first() diff --git a/tests/apps/courses/test_templatetags_extra_tags_course_runs_list_widget_props.py b/tests/apps/courses/test_templatetags_extra_tags_course_runs_list_widget_props.py index 65c22317a6..01c86a749b 100644 --- a/tests/apps/courses/test_templatetags_extra_tags_course_runs_list_widget_props.py +++ b/tests/apps/courses/test_templatetags_extra_tags_course_runs_list_widget_props.py @@ -28,7 +28,14 @@ def test_templatetags_course_runs_list_widget_props_tag(self): a visibility_catalog different from "hidden" and not to be scheduled. """ course = factories.CourseFactory(should_publish=True) - course_run = factories.CourseRunFactory(direct_course=course) + course_run = factories.CourseRunFactory( + direct_course=course, + offer="paid", + price="59.99", + certificate_offer="paid", + certificate_price="29.99", + ) + # Create a hidden course run factories.CourseRunFactory( catalog_visibility=CourseRunCatalogVisibility.HIDDEN, @@ -87,6 +94,11 @@ def test_templatetags_course_runs_list_widget_props_tag(self): "catalog_visibility": course_run.catalog_visibility, "display_mode": "detailed", "snapshot": None, + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "29.99", + "certificate_offer": "paid", } ], "maxArchivedCourseRuns": 10, @@ -102,7 +114,13 @@ def test_templatetags_course_runs_list_widget_props_tag_with_snapshot(self): """ course = factories.CourseFactory(should_publish=True) snapshot = factories.CourseFactory(page_parent=course.extended_object) - course_run = factories.CourseRunFactory(direct_course=snapshot) + course_run = factories.CourseRunFactory( + direct_course=snapshot, + offer="paid", + price="59.99", + certificate_offer="paid", + certificate_price="29.99", + ) request = RequestFactory().get("/") request.current_page = course.extended_object @@ -151,6 +169,11 @@ def test_templatetags_course_runs_list_widget_props_tag_with_snapshot(self): "catalog_visibility": course_run.catalog_visibility, "display_mode": "detailed", "snapshot": snapshot.extended_object.get_absolute_url(), + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "29.99", + "certificate_offer": "paid", } ], "maxArchivedCourseRuns": 10, @@ -168,15 +191,30 @@ def test_templatetags_course_runs_list_widget_props_tag_edit_mode(self): course = factories.CourseFactory( code="DemoX", page_languages=["en"], should_publish=False ) - factories.CourseRunFactory(direct_course=course) + factories.CourseRunFactory( + direct_course=course, + offer="paid", + price="59.99", + certificate_offer="paid", + certificate_price="29.99", + ) # Create a hidden course run factories.CourseRunFactory( - catalog_visibility=CourseRunCatalogVisibility.HIDDEN, direct_course=course + catalog_visibility=CourseRunCatalogVisibility.HIDDEN, + direct_course=course, + offer="paid", + price="59.99", + certificate_offer="paid", + certificate_price="29.99", ) # Create a "to be scheduled" course run factories.CourseRunFactory( start=None, direct_course=course, + offer="paid", + price="59.99", + certificate_offer="paid", + certificate_price="29.99", ) page = course.extended_object @@ -232,6 +270,11 @@ def test_templatetags_course_runs_list_widget_props_tag_edit_mode(self): "catalog_visibility": course_run.catalog_visibility, "display_mode": "detailed", "snapshot": None, + "price": "59.99", + "price_currency": "EUR", + "offer": "paid", + "certificate_price": "29.99", + "certificate_offer": "paid", } for course_run in course.course_runs.all() ],