From 688a379dacc37af894d84b71f6573682021c05a0 Mon Sep 17 00:00:00 2001 From: Tiago-Salles Date: Fri, 26 Jul 2024 09:54:59 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20offer=20and=20price?= =?UTF-8?q?=20fields=20to=20courseRun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../backend/{{cookiecutter.site}}/settings.py | 7 + src/frontend/js/types/index.ts | 5 + .../js/utils/test/factories/richie.ts | 16 + .../components/SyllabusCourseRun/index.tsx | 103 ++++ .../SyllabusCourseRunCompacted/index.tsx | 103 ++++ .../SyllabusCourseRunsList/index.spec.tsx | 547 +++++++++++++++++- src/richie/apps/courses/admin.py | 10 +- src/richie/apps/courses/factories.py | 31 + ...36_courserun_certificate_offer_and_more.py | 83 +++ src/richie/apps/courses/models/course.py | 59 +- src/richie/apps/courses/serializers.py | 15 + tests/apps/courses/test_admin_course_run.py | 34 +- .../courses/test_admin_form_course_run.py | 24 +- .../apps/courses/test_api_course_run_sync.py | 184 +++++- tests/apps/courses/test_models_course.py | 2 +- tests/apps/courses/test_models_course_run.py | 3 +- 17 files changed, 1189 insertions(+), 38 deletions(-) create mode 100644 src/richie/apps/courses/migrations/0036_courserun_certificate_offer_and_more.py 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..ac251f8b1f 100644 --- a/src/frontend/js/utils/test/factories/richie.ts +++ b/src/frontend/js/utils/test/factories/richie.ts @@ -46,6 +46,17 @@ export const CourseStateFutureOpenFactory = factory(() => { }); export const CourseRunFactory = factory(() => { + + var 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 +69,11 @@ export const CourseRunFactory = factory(() => { dashboard_link: null, title: faker.lorem.sentence(3), display_mode: CourseRunDisplayMode.DETAILED, + price: price, + price_currency: currency, + offer: 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..0086cdc72b 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) : '...'; + var courseOfferMessage = undefined; + var certificationOfferMessage = undefined; + var enrollmentPrice = ""; + var 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,26 @@ 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..b790e176de 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 !== '...'; + var courseOfferMessage = undefined; + var certificationOfferMessage = undefined; + var enrollmentPrice = ""; + var 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,26 @@ 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..1cdf650cd2 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)!, }); @@ -1024,4 +1025,546 @@ 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") + }); +}); \ No newline at end of file diff --git a/src/richie/apps/courses/admin.py b/src/richie/apps/courses/admin.py index 4b7c3029ba..c96b206b31 100644 --- a/src/richie/apps/courses/admin.py +++ b/src/richie/apps/courses/admin.py @@ -57,6 +57,10 @@ class Meta: "languages", "enrollment_count", "catalog_visibility", + "offer", + "price", + "certificate_offer", + "certificate_price", "sync_mode", "display_mode", ] @@ -77,7 +81,7 @@ def __init__(self, *args, **kwargs): if "direct_course" not in self.fields: return - + if self.instance.pk: course_query = ( self.instance.get_course() @@ -150,6 +154,10 @@ class CourseRunAdmin(FrontendEditableAdminMixin, TranslatableAdmin): "languages", "enrollment_count", "catalog_visibility", + "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..58acc5ace3 100644 --- a/src/richie/apps/courses/factories.py +++ b/src/richie/apps/courses/factories.py @@ -5,6 +5,7 @@ import random from collections import namedtuple from datetime import datetime, timedelta, timezone +from decimal import Decimal from django.conf import settings from django.utils import timezone as django_timezone @@ -13,6 +14,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 +443,10 @@ class Meta: resource_link = factory.Faker("uri") sync_mode = models.CourseRunSyncMode.SYNC_TO_PUBLIC display_mode = models.CourseRunDisplayMode.DETAILED + price = factory.Faker( + "pydecimal", min_value=1, max_value=100, left_digits=5, right_digits=2 + ) + price_currency = "EUR" # pylint: disable=no-self-use @factory.lazy_attribute @@ -529,6 +535,31 @@ 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_price(self): + if self.offer == CourseRunOffer.FREE: + return Decimal("0.0") + + return Decimal(str(random.randrange(0, 100))) + + @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..0bf715e5c6 100644 --- a/src/richie/apps/courses/models/course.py +++ b/src/richie/apps/courses/models/course.py @@ -685,7 +685,6 @@ def save(self, *args, **kwargs): self.full_clean() super().save(*args, **kwargs) - class CourseRunSyncMode(models.TextChoices): """Course run synchronization mode choices for the "sync_mode" field.""" @@ -706,6 +705,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 +764,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 +796,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..14dde1e16c 100644 --- a/tests/apps/courses/test_admin_course_run.py +++ b/tests/apps/courses/test_admin_course_run.py @@ -346,6 +346,10 @@ 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", + "offer": "paid", + "price": 59.98, + "certificate_offer": "paid", + "certificate_price": 59.98, "sync_mode": "manual", "display_mode": "detailed", } @@ -434,6 +438,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 +455,10 @@ 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", + "offer": "paid", + "price": "59.98", + "certificate_offer": "paid", + "certificate_price": "29.98", "sync_mode": "manual", "display_mode": "detailed", } @@ -479,13 +488,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 +516,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 +531,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 +551,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..550c5d6fde 100644 --- a/tests/apps/courses/test_admin_form_course_run.py +++ b/tests/apps/courses/test_admin_form_course_run.py @@ -36,9 +36,14 @@ def _get_admin_form(course, user): "enrollment_end_0": "2015-01-23", "enrollment_end_1": "09:07:11", "catalog_visibility": "course_and_search", + "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 +115,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..e73c4bcd5d 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,70 @@ 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): + + 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.py b/tests/apps/courses/test_models_course.py index 6a7b3be29a..2e8204d69c 100644 --- a/tests/apps/courses/test_models_course.py +++ b/tests/apps/courses/test_models_course.py @@ -1718,4 +1718,4 @@ def test_models_course_get_pace_display_with_uncomputable_value(self): If pace is uncomputable, None should be return. """ course = factories.CourseFactory(duration=[7, "hour"], effort=[7, "hour"]) - self.assertIsNone(course.get_pace_display()) + self.assertIsNone(course.get_pace_display()) \ No newline at end of file diff --git a/tests/apps/courses/test_models_course_run.py b/tests/apps/courses/test_models_course_run.py index 29f772f2a0..e62f386d43 100644 --- a/tests/apps/courses/test_models_course_run.py +++ b/tests/apps/courses/test_models_course_run.py @@ -19,7 +19,7 @@ from richie.apps.core.helpers import create_i18n_page from richie.apps.courses.factories import CourseFactory, CourseRunFactory from richie.apps.courses.models import CourseRun, CourseRunTranslation -from richie.apps.courses.models.course import CourseRunCatalogVisibility +from richie.apps.courses.models.course import CourseRunCatalogVisibility, CourseRunOffer # pylint: disable=too-many-public-methods @@ -623,6 +623,7 @@ def test_models_course_run_mark_dirty_any_field(self): stub = CourseRunFactory( sync_mode="manual", catalog_visibility=CourseRunCatalogVisibility.COURSE_ONLY, + price=3.0, display_mode="compact", ) # New random values to update our course run