From e951abc294ed11041874cf2cf7ace443b1b1b3c6 Mon Sep 17 00:00:00 2001 From: Asad Iqbal <7334669+asadiqbal08@users.noreply.github.com> Date: Fri, 15 Jan 2021 11:48:42 +0500 Subject: [PATCH] External/3rd Party Programs (#2062) * External Page for Program (CMS) * Filter and Sort Program Pages * Changes catalog page implementation to support External Programs * Added support for External Programs in product page * Fixed carousal card data url Co-authored-by: Arslan Ashraf Co-authored-by: Arslan Ashraf <34372316+arslanashraf7@users.noreply.github.com> --- cms/api.py | 28 ++- cms/api_test.py | 30 ++- cms/factories.py | 34 +++ cms/migrations/0047_externalprogrampage.py | 199 ++++++++++++++++ ...0048_external_course_selection_carousel.py | 34 +++ cms/models.py | 157 ++++++++++++- cms/models_test.py | 216 +++++++++++++++++- cms/templates/catalog_page.html | 2 +- cms/templates/partials/card_details_top.html | 10 +- .../partials/courseware-carousel-base.html | 2 +- cms/templates/partials/enroll_button.html | 2 +- cms/templates/partials/metadata-tiles.html | 4 +- cms/templates/product_page.html | 4 +- 13 files changed, 694 insertions(+), 28 deletions(-) create mode 100644 cms/migrations/0047_externalprogrampage.py create mode 100644 cms/migrations/0048_external_course_selection_carousel.py diff --git a/cms/api.py b/cms/api.py index 7f67ca540..fba008e7e 100644 --- a/cms/api.py +++ b/cms/api.py @@ -14,7 +14,9 @@ DEFAULT_SITE_PROPS = dict(hostname="localhost", port=80) -def filter_and_sort_catalog_pages(program_pages, course_pages, external_course_pages): +def filter_and_sort_catalog_pages( + program_pages, course_pages, external_course_pages, external_program_pages +): """ Filters program and course pages to only include those that should be visible in the catalog, then returns a tuple of sorted lists of pages @@ -23,10 +25,11 @@ def filter_and_sort_catalog_pages(program_pages, course_pages, external_course_p program_pages (iterable of ProgramPage): ProgramPages to filter and sort course_pages (iterable of CoursePage): CoursePages to filter and sort external_course_pages (iterable of ExternalCoursePage): ExternalCoursePages to filter and sort + external_program_pages (iterable of ExternalProgramPage): ExternalProgramPages to filter and sort Returns: - tuple of (list of Pages): A tuple containing a list of combined ProgramPages, CoursePages an ExternalCoursePages, a list of - ProgramPages, and a list of CoursePages and ExternalCoursePages, all sorted by the next course run date and title + tuple of (list of Pages): A tuple containing a list of combined ProgramPages, CoursePages, ExternalCoursePages and ExternalProgramPages, a list of + ProgramPages and ExternalProgramPages, and a list of CoursePages and ExternalCoursePages, all sorted by the next course/program run date and title """ valid_program_pages = [ page for page in program_pages if page.product.is_catalog_visible @@ -37,20 +40,26 @@ def filter_and_sort_catalog_pages(program_pages, course_pages, external_course_p valid_external_course_pages = list(external_course_pages) + valid_external_program_pages = list(external_program_pages) + page_run_dates = { page: ( - page.next_run_date - if page.is_external_course_page - else page.product.next_run_date + page.next_run_date if page.is_external_page else page.product.next_run_date ) or datetime(year=MINYEAR, month=1, day=1, tzinfo=pytz.UTC) for page in itertools.chain( - valid_program_pages, valid_course_pages, valid_external_course_pages + valid_program_pages, + valid_course_pages, + valid_external_course_pages, + valid_external_program_pages, ) } return ( sorted( - valid_program_pages + valid_course_pages + valid_external_course_pages, + valid_program_pages + + valid_external_program_pages + + valid_course_pages + + valid_external_course_pages, # ProgramPages with the same next run date as a CoursePage should be sorted first key=lambda page: ( page_run_dates[page], @@ -59,7 +68,8 @@ def filter_and_sort_catalog_pages(program_pages, course_pages, external_course_p ), ), sorted( - valid_program_pages, key=lambda page: (page_run_dates[page], page.title) + valid_program_pages + valid_external_program_pages, + key=lambda page: (page_run_dates[page], page.title), ), sorted( valid_course_pages + valid_external_course_pages, diff --git a/cms/api_test.py b/cms/api_test.py index 87c53ed2a..2edfca130 100644 --- a/cms/api_test.py +++ b/cms/api_test.py @@ -6,6 +6,7 @@ from cms.factories import ( CoursePageFactory, ExternalCoursePageFactory, + ExternalProgramPageFactory, ProgramPageFactory, ) from cms.models import ExternalCoursePage @@ -24,6 +25,9 @@ def test_filter_and_sort_catalog_pages(): # pylint:disable=too-many-locals now = now_in_utc() earlier_external_course_page = ExternalCoursePageFactory.create(start_date=now) + earlier_external_program_page = ExternalProgramPageFactory.create( + start_date=now, course_count=2 + ) non_program_run = CourseRunFactory.create( course__no_program=True, start_date=(now + timedelta(days=1)) ) @@ -32,6 +36,10 @@ def test_filter_and_sort_catalog_pages(): # pylint:disable=too-many-locals later_external_course_page = ExternalCoursePageFactory.create( start_date=now + timedelta(days=4) ) + + later_external_program_page = ExternalProgramPageFactory.create( + start_date=now + timedelta(days=4) + ) # Create course run with past start_date and future enrollment_end, which should appear in the catalog future_enrollment_end_run = CourseRunFactory.create( past_start=True, @@ -52,6 +60,11 @@ def test_filter_and_sort_catalog_pages(): # pylint:disable=too-many-locals external_course_pages = [earlier_external_course_page, later_external_course_page] + external_program_pages = [ + earlier_external_program_page, + later_external_program_page, + ] + initial_course_pages = CoursePageFactory.create_batch( len(all_runs), course=factory.Iterator(run.course for run in all_runs) ) @@ -63,7 +76,10 @@ def test_filter_and_sort_catalog_pages(): # pylint:disable=too-many-locals ) all_pages, program_pages, course_pages = filter_and_sort_catalog_pages( - initial_program_pages, initial_course_pages, external_course_pages + initial_program_pages, + initial_course_pages, + external_course_pages, + external_program_pages, ) # Combined pages and course pages should not include the past course run @@ -71,21 +87,31 @@ def test_filter_and_sort_catalog_pages(): # pylint:disable=too-many-locals len(initial_program_pages) + len(initial_course_pages) + len(external_course_pages) + + len(external_program_pages) - 1 ) assert len(course_pages) == ( len(initial_course_pages) + len(external_course_pages) - 1 ) + assert len(program_pages) == ( + len(initial_program_pages) + len(external_program_pages) + ) + # Filtered out external course page because it does not have a `course` attribute assert past_run.course not in ( None if page.is_external_course_page else page.course for page in course_pages ) # Pages should be sorted by next run date - assert [page.program for page in program_pages] == [ + assert [ + page if page.is_external_program_page else page.program + for page in program_pages + ] == [ + earlier_external_program_page, first_program_run.course.program, second_program_run.course.program, + later_external_program_page, ] expected_course_run_sort = [ future_enrollment_end_run, diff --git a/cms/factories.py b/cms/factories.py index a237aa909..10118cda3 100644 --- a/cms/factories.py +++ b/cms/factories.py @@ -21,6 +21,7 @@ CoursePage, CoursesInProgramPage, ExternalCoursePage, + ExternalProgramPage, FacultyMembersPage, ForTeamsPage, FrequentlyAskedQuestion, @@ -135,6 +136,39 @@ def post_gen(obj, create, extracted, **kwargs): # pylint:disable=unused-argumen return obj +class ExternalProgramPageFactory(wagtail_factories.PageFactory): + """ExternalProgramPage factory class""" + + title = factory.Sequence("Test page - External Program {0}".format) + start_date = factory.Faker( + "date_time_this_month", before_now=True, after_now=False, tzinfo=pytz.utc + ) + price = factory.fuzzy.FuzzyDecimal(low=1, high=123) + external_url = factory.Faker("uri") + readable_id = factory.Sequence( + lambda number: "external-course:/v{}/{}".format(number, FAKE.slug()) + ) + subhead = factory.fuzzy.FuzzyText(prefix="Subhead ") + thumbnail_image = factory.SubFactory(wagtail_factories.ImageFactory) + background_image = factory.SubFactory(wagtail_factories.ImageFactory) + course_count = factory.fuzzy.FuzzyInteger(1) + + class Meta: + model = ExternalProgramPage + + @factory.post_generation + def post_gen(obj, create, extracted, **kwargs): # pylint:disable=unused-argument + """Post-generation hook""" + if create: + # Move the created page to be a child of the program index page + index_page = ProgramIndexPage.objects.first() + if not index_page: + raise ObjectDoesNotExist + obj.move(index_page, "last-child") + obj.refresh_from_db() + return obj + + class LearningOutcomesPageFactory(wagtail_factories.PageFactory): """LearningOutcomesPage factory class""" diff --git a/cms/migrations/0047_externalprogrampage.py b/cms/migrations/0047_externalprogrampage.py new file mode 100644 index 000000000..f5f5ac0bf --- /dev/null +++ b/cms/migrations/0047_externalprogrampage.py @@ -0,0 +1,199 @@ +# Generated by Django 2.2.13 on 2021-01-06 09:51 + +from django.db import migrations, models +import django.db.models.deletion +import wagtail.core.blocks +import wagtail.core.fields +import wagtail.images.blocks +import wagtailmetadata.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("wagtailimages", "0022_uploadedimage"), + ("wagtailcore", "0045_assign_unlock_grouppagepermission"), + ("cms", "0046_page_data_migrations"), + ] + + operations = [ + migrations.CreateModel( + name="ExternalProgramPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.Page", + ), + ), + ( + "description", + wagtail.core.fields.RichTextField( + blank=True, + help_text="The description shown on the product page", + ), + ), + ( + "catalog_details", + wagtail.core.fields.RichTextField( + blank=True, + help_text="The description shown on the catalog page for this product", + ), + ), + ( + "subhead", + models.CharField( + help_text="A short subheading to appear below the title on the program/course page", + max_length=255, + ), + ), + ( + "video_title", + wagtail.core.fields.RichTextField( + blank=True, + help_text="The title to be displayed for the program/course video", + ), + ), + ( + "video_url", + models.URLField( + blank=True, + help_text="URL to the video to be displayed for this program/course. It can be an HLS or Youtube video URL.", + null=True, + ), + ), + ( + "duration", + models.CharField( + blank=True, + help_text="A short description indicating how long it takes to complete (e.g. '4 weeks')", + max_length=50, + null=True, + ), + ), + ( + "background_video_url", + models.URLField( + blank=True, + help_text="Background video that should play over the hero section. Must be an HLS video URL. Will cover background image if selected.", + null=True, + ), + ), + ( + "time_commitment", + models.CharField( + blank=True, + help_text="A short description indicating about the time commitments.", + max_length=100, + null=True, + ), + ), + ( + "featured", + models.BooleanField( + blank=True, + default=False, + help_text="When checked, product will be shown as featured.", + ), + ), + ( + "content", + wagtail.core.fields.StreamField( + [ + ( + "heading", + wagtail.core.blocks.CharBlock(classname="full title"), + ), + ("paragraph", wagtail.core.blocks.RichTextBlock()), + ("image", wagtail.images.blocks.ImageChooserBlock()), + ("raw_html", wagtail.core.blocks.RawHTMLBlock()), + ], + blank=True, + help_text="The content of this tab on the program page", + ), + ), + ( + "external_url", + models.URLField( + help_text="The URL of the external program web page." + ), + ), + ( + "readable_id", + models.CharField( + help_text="The readable ID of the external program. Appears in URL, has to be unique.", + max_length=64, + unique=True, + ), + ), + ( + "start_date", + models.DateField( + blank=True, + help_text="The start date of the external program.", + null=True, + ), + ), + ( + "price", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="The price of the external program.", + max_digits=20, + null=True, + ), + ), + ( + "course_count", + models.IntegerField( + help_text="The number of total courses in the external program." + ), + ), + ( + "background_image", + models.ForeignKey( + blank=True, + help_text="Background image size must be at least 1900x650 pixels.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.Image", + ), + ), + ( + "search_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.Image", + verbose_name="Search image", + ), + ), + ( + "thumbnail_image", + models.ForeignKey( + blank=True, + help_text="Thumbnail size must be at least 550x310 pixels.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.Image", + ), + ), + ], + options={"abstract": False}, + bases=( + wagtailmetadata.models.MetadataMixin, + "wagtailcore.page", + models.Model, + ), + ) + ] diff --git a/cms/migrations/0048_external_course_selection_carousel.py b/cms/migrations/0048_external_course_selection_carousel.py new file mode 100644 index 000000000..4e550d063 --- /dev/null +++ b/cms/migrations/0048_external_course_selection_carousel.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.13 on 2021-01-11 12:37 + +from django.db import migrations +import wagtail.core.blocks +import wagtail.core.fields + + +class Migration(migrations.Migration): + + dependencies = [("cms", "0047_externalprogrampage")] + + operations = [ + migrations.AlterField( + model_name="coursesinprogrampage", + name="contents", + field=wagtail.core.fields.StreamField( + [ + ( + "item", + wagtail.core.blocks.PageChooserBlock( + page_type=[ + "cms.CoursePage", + "cms.ProgramPage", + "cms.ExternalCoursePage", + ], + required=False, + ), + ) + ], + blank=True, + help_text="The courseware to display in this carousel", + ), + ) + ] diff --git a/cms/models.py b/cms/models.py index e592dc321..713078117 100644 --- a/cms/models.py +++ b/cms/models.py @@ -76,7 +76,7 @@ def get_child_by_readable_id(self, readable_id): """Fetch a child page by a Program/Course readable_id value""" raise NotImplementedError - def get_external_child_by_readable_id(self, readable_id): + def get_external_course_child_by_readable_id(self, readable_id): """Fetch a child page by slug: typically used in external courseware pages""" return ( self.get_children() @@ -84,6 +84,14 @@ def get_external_child_by_readable_id(self, readable_id): .get(externalcoursepage__readable_id=readable_id) ) + def get_external_program_child_by_readable_id(self, readable_id): + """Fetch a child page by slug: typically used in external program pages""" + return ( + self.get_children() + .type(ExternalProgramPage) + .get(externalprogrampage__readable_id=readable_id) + ) + def route(self, request, path_components): if path_components: # request is for a child of this page @@ -97,11 +105,17 @@ def route(self, request, path_components): except Page.DoesNotExist: try: # Try to find an external course page - subpage = self.get_external_child_by_readable_id( + subpage = self.get_external_course_child_by_readable_id( readable_id=child_readable_id ) except Page.DoesNotExist: - raise Http404 + try: + # Try to find an external program page + subpage = self.get_external_program_child_by_readable_id( + readable_id=child_readable_id + ) + except Page.DoesNotExist: + raise Http404 return subpage.specific.route(request, remaining_components) return super().route(request, path_components) @@ -239,6 +253,12 @@ def get_context(self, request, *args, **kwargs): .select_related("thumbnail_image") ) + external_program_qset = ( + ExternalProgramPage.objects.live() + .order_by("title") + .select_related("thumbnail_image") + ) + featured_product = ProgramPage.objects.filter( featured=True, program__live=True ).select_related("program", "thumbnail_image").prefetch_related( @@ -247,7 +267,10 @@ def get_context(self, request, *args, **kwargs): featured=True, course__live=True ) all_pages, program_pages, course_pages = filter_and_sort_catalog_pages( - program_page_qset, course_page_qset, external_course_qset + program_page_qset, + course_page_qset, + external_course_qset, + external_program_qset, ) return dict( **super().get_context(request), @@ -669,11 +692,21 @@ def is_external_course_page(self): """Checks whether the page in question is for an external course or not.""" return isinstance(self, ExternalCoursePage) + @property + def is_external_program_page(self): + """Checks whether the page in question is for an external program or not.""" + return isinstance(self, ExternalProgramPage) + @property def is_program_page(self): """Gets the product page type, this is used for sorting product pages.""" return isinstance(self, ProgramPage) + @property + def is_external_page(self): + """Checks whether the page in question is for an external course/program page or not.""" + return self.is_external_program_page or self.is_external_course_page + class ProgramPage(ProductPage): """ @@ -925,6 +958,105 @@ def next_run_date(self): ) +class ExternalProgramPage(ProductPage): + """ + CMS page representing an external program. + """ + + template = "product_page.html" + + parent_page_types = ["ProgramIndexPage"] + + external_url = models.URLField( + null=False, blank=False, help_text="The URL of the external program web page." + ) + readable_id = models.CharField( + max_length=64, + null=False, + blank=False, + unique=True, + help_text="The readable ID of the external program. Appears in URL, has to be unique.", + ) + start_date = models.DateField( + null=True, blank=True, help_text="The start date of the external program." + ) + price = models.DecimalField( + null=True, + blank=True, + decimal_places=2, + max_digits=20, + help_text="The price of the external program.", + ) + + course_count = models.IntegerField( + blank=False, + null=False, + help_text="The number of total courses in the external program.", + ) + + content_panels = [ + FieldPanel("external_url"), + FieldPanel("readable_id"), + FieldPanel("course_count"), + FieldPanel("start_date"), + FieldPanel("price"), + ] + ProductPage.content_panels + + def get_url_parts(self, request=None): + # We need to skip `ProductPage` in the MRO and get to `Page.get_url_parts` + # pylint: disable=bad-super-call + url_parts = super(ProductPage, self).get_url_parts(request=request) + if not url_parts: + return None + return ( + url_parts[0], + url_parts[1], + # Wagtail generates the 'page_path' part of the url tuple with the + # parent page slug followed by this page's slug (e.g.: "/programs/my-page-title"). + # We want to generate that path with the parent page slug followed by the readable_id + # of the external program instead (e.g.: "/programs/external:some+external+program") + re.sub( + self.slugged_page_path_pattern, + r"\1{}\3".format(self.readable_id), + url_parts[2], + ), + ) + + @property + def program_page(self): + """ + External programs are not related to local programs + """ + return None + + @property + def course_lineup(self): + """Gets the (course carousel page/course lineup) for external program""" + return self._get_child_page_of_type(CoursesInProgramPage) + + @property + def course_pages(self): + """ + There is no program associated with external programs so there would be + no course_pages value + """ + return None + + @property + def product(self): + """There is no product associated with external programs""" + return None + + @property + def next_run_date(self): + """The next run date should be only if `start_date` is in the future""" + return ( + datetime.combine(self.start_date, datetime.min.time(), tzinfo=pytz.UTC) + if self.start_date and self.start_date > date.today() + else None + ) + + class CourseProgramChildPage(Page): """ Abstract page representing a child of Course/Program Page @@ -933,7 +1065,13 @@ class CourseProgramChildPage(Page): class Meta: abstract = True - parent_page_types = ["ExternalCoursePage", "CoursePage", "ProgramPage", "HomePage"] + parent_page_types = [ + "ExternalCoursePage", + "CoursePage", + "ProgramPage", + "HomePage", + "ExternalProgramPage", + ] # disable promote panels, no need for slug entry, it will be autogenerated promote_panels = [] @@ -1235,7 +1373,7 @@ class CoursesInProgramPage(CourseProgramChildPage): """ # We need this to be only under a program page and home page - parent_page_types = ["ProgramPage", "HomePage"] + parent_page_types = ["ProgramPage", "ExternalProgramPage", "HomePage"] heading = models.CharField( max_length=255, help_text="The heading to show in this section" @@ -1256,7 +1394,12 @@ class CoursesInProgramPage(CourseProgramChildPage): ( "item", PageChooserBlock( - required=False, target_model=["cms.CoursePage", "cms.ProgramPage"] + required=False, + target_model=[ + "cms.CoursePage", + "cms.ProgramPage", + "cms.ExternalCoursePage", + ], ), ) ], diff --git a/cms/models_test.py b/cms/models_test.py index c1b0e019d..357a8ea57 100644 --- a/cms/models_test.py +++ b/cms/models_test.py @@ -1,5 +1,5 @@ """ Tests for cms pages. """ - +# pylint: disable=too-many-lines import json import pytest import factory @@ -27,6 +27,7 @@ TextSectionFactory, CertificatePageFactory, ExternalCoursePageFactory, + ExternalProgramPageFactory, ) from cms.models import ( UserTestimonialsPage, @@ -109,6 +110,9 @@ def test_custom_detail_page_urls(): program_pages = ProgramPageFactory.create_batch( 2, program__readable_id=factory.Iterator([readable_id, "non-matching-id"]) ) + external_program_pages = ExternalProgramPageFactory.create_batch( + 2, readable_id=factory.Iterator([readable_id, "non-matching-external-id"]) + ) course_pages = CoursePageFactory.create_batch( 2, course__readable_id=factory.Iterator([readable_id, "non-matching-id"]) ) @@ -119,6 +123,7 @@ def test_custom_detail_page_urls(): ), ) assert program_pages[0].get_url() == "/programs/{}/".format(readable_id) + assert external_program_pages[0].get_url() == "/programs/{}/".format(readable_id) assert course_pages[0].get_url() == "/courses/{}/".format(readable_id) assert external_course_pages[0].get_url() == "/courses/{}/".format( external_readable_id @@ -270,6 +275,19 @@ def test_program_page_faculty_subpage(): _assert_faculty_members(program_page) +def test_external_program_page_faculty_subpage(): + """ + FacultyMembersPage should return expected values if associated with ExternalProgramPage + """ + external_program_page = ExternalProgramPageFactory.create() + + assert not external_program_page.faculty + FacultyMembersPageFactory.create( + parent=external_program_page, members=json.dumps(_get_faculty_members()) + ) + _assert_faculty_members(external_program_page) + + def test_course_page_faculty_subpage(): """ FacultyMembersPage should return expected values if associated with CoursePage @@ -394,6 +412,31 @@ def test_program_page_testimonials(): assert testimonial.value.get("quote") == "quote" +def test_external_program_page_testimonials(): + """ + testimonials property should return expected value if associated with an ExternalProgramPage + """ + external_program_page = ExternalProgramPageFactory.create() + assert UserTestimonialsPage.can_create_at(external_program_page) + testimonials_page = UserTestimonialsPageFactory.create( + parent=external_program_page, + heading="heading", + subhead="subhead", + items__0__testimonial__name="name", + items__0__testimonial__title="title", + items__0__testimonial__image__title="image", + items__0__testimonial__quote="quote", + ) + assert external_program_page.testimonials == testimonials_page + assert testimonials_page.heading == "heading" + assert testimonials_page.subhead == "subhead" + for testimonial in testimonials_page.items: # pylint: disable=not-an-iterable + assert testimonial.value.get("name") == "name" + assert testimonial.value.get("title") == "title" + assert testimonial.value.get("image").title == "image" + assert testimonial.value.get("quote") == "quote" + + def test_child_page_unroutable(): """ Child page should not provide a URL if it unroutable @@ -498,6 +541,26 @@ def test_program_page_for_teams(): assert not ForTeamsPage.can_create_at(program_page) +def test_external_program_page_for_teams(): + """ + The ForTeams property should return expected values if associated with an ExternalProgramPage + """ + external_program_page = ExternalProgramPageFactory.create() + assert ForTeamsPage.can_create_at(external_program_page) + teams_page = ForTeamsPageFactory.create( + parent=external_program_page, + content="

content

", + switch_layout=True, + dark_theme=True, + action_title="Action Title", + ) + assert external_program_page.for_teams == teams_page + assert teams_page.action_title == "Action Title" + assert teams_page.content == "

content

" + assert teams_page.switch_layout + assert teams_page.dark_theme + + def test_program_page_course_lineup(): """ course_lineup property should return expected values if associated with a ProgramPage @@ -548,6 +611,18 @@ def test_program_page_faq_property(): assert list(program_page.faqs) == [faq] +def test_external_program_page_faq_property(): + """ Faqs property should return list of faqs related to given ExternalProgramPage""" + external_program_page = ExternalProgramPageFactory.create() + assert FrequentlyAskedQuestionPage.can_create_at(external_program_page) + + faqs_page = FrequentlyAskedQuestionPageFactory.create(parent=external_program_page) + faq = FrequentlyAskedQuestionFactory.create(faqs_page=faqs_page) + + assert faqs_page.get_parent() is external_program_page + assert list(external_program_page.faqs) == [faq] + + def test_course_page_properties(): """ Wagtail-page-related properties should return expected values @@ -625,6 +700,32 @@ def test_program_page_properties(): assert program_page.background_image.title == "background-image" +def test_external_program_page_properties(): + """ + Wagtail-page-related properties for ExternalProgramPage should return expected values + """ + external_program_page = ExternalProgramPageFactory.create( + title="

page title

", + subhead="subhead", + description="

desc

", + catalog_details="

catalog desc

", + duration="1 week", + video_title="

title

", + video_url="http://test.com/mock.mp4", + background_image__title="background-image", + course_count=2, + ) + assert external_program_page.title == "

page title

" + assert external_program_page.subhead == "subhead" + assert external_program_page.description == "

desc

" + assert external_program_page.catalog_details == "

catalog desc

" + assert external_program_page.duration == "1 week" + assert external_program_page.video_title == "

title

" + assert external_program_page.video_url == "http://test.com/mock.mp4" + assert external_program_page.background_image.title == "background-image" + assert external_program_page.course_count == 2 + + def test_course_page_learning_outcomes(): """ CoursePage related LearningOutcomesPage should return expected values if it exists @@ -704,6 +805,31 @@ def test_program_learning_outcomes(): assert not LearningOutcomesPage.can_create_at(program_page) +def test_external_program_learning_outcomes(): + """ + ExternalProgramPage related LearningOutcomesPage should return expected values if it exists + """ + external_program_page = ExternalProgramPageFactory.create() + + assert LearningOutcomesPage.can_create_at(external_program_page) + + learning_outcomes_page = LearningOutcomesPageFactory( + parent=external_program_page, + heading="heading", + sub_heading="subheading", + outcome_items=json.dumps([{"type": "outcome", "value": "benefit"}]), + ) + assert learning_outcomes_page.get_parent() == external_program_page + assert learning_outcomes_page.heading == "heading" + for ( + block + ) in learning_outcomes_page.outcome_items: # pylint: disable=not-an-iterable + assert block.block_type == "outcome" + assert block.value == "benefit" + assert external_program_page.outcomes == learning_outcomes_page + assert not LearningOutcomesPage.can_create_at(external_program_page) + + def test_course_page_learning_techniques(): """ CoursePage related subpages should return expected values if they exist @@ -775,6 +901,31 @@ def test_program_page_learning_techniques(): assert technique.value.get("image").title == "image-title" +def test_external_program_page_learning_techniques(): + """ + ExternalProgramPage related subpages should return expected values if they exist + ExternalProgramPage related LearningTechniquesPage should return expected values if it exists + """ + external_program_page = ExternalProgramPageFactory.create( + description="

desc

", duration="1 week" + ) + + assert LearningTechniquesPage.can_create_at(external_program_page) + learning_techniques_page = LearningTechniquesPageFactory( + parent=external_program_page, + technique_items__0__techniques__heading="heading", + technique_items__0__techniques__sub_heading="sub_heading", + technique_items__0__techniques__image__title="image-title", + ) + assert learning_techniques_page.get_parent() == external_program_page + for ( + technique + ) in learning_techniques_page.technique_items: # pylint: disable=not-an-iterable + assert technique.value.get("heading") == "heading" + assert technique.value.get("sub_heading") == "sub_heading" + assert technique.value.get("image").title == "image-title" + + def test_program_page_who_should_enroll(): """ ProgramPage related WhoShouldEnrollPage should return expected values if it exists @@ -810,6 +961,41 @@ def test_program_page_who_should_enroll(): assert who_should_enroll_page.heading == new_heading +def test_external_program_page_who_should_enroll(): + """ + ExternalProgramPage related WhoShouldEnrollPage should return expected values if it exists + """ + external_program_page = ExternalProgramPageFactory.create() + + assert WhoShouldEnrollPage.can_create_at(external_program_page) + who_should_enroll_page = WhoShouldEnrollPageFactory.create( + parent=external_program_page, + content=json.dumps( + [ + {"type": "item", "value": "

item

"}, + {"type": "item", "value": "

item

"}, + ] + ), + ) + assert who_should_enroll_page.get_parent() == external_program_page + assert len(who_should_enroll_page.content) == 2 + for block in who_should_enroll_page.content: # pylint: disable=not-an-iterable + assert block.block_type == "item" + assert block.value.source == "

item

" + assert external_program_page.who_should_enroll == who_should_enroll_page + assert not WhoShouldEnrollPage.can_create_at(external_program_page) + + # default page hedding + assert who_should_enroll_page.heading == "Who Should Enroll" + + # test that it can be modified + new_heading = "New heading of the page" + who_should_enroll_page.heading = new_heading + who_should_enroll_page.save() + + assert who_should_enroll_page.heading == new_heading + + def test_course_page_propel_career(): """ The propel_career property should return expected values if associated with a CoursePage @@ -863,6 +1049,23 @@ def test_program_page_propel_career(): assert propel_career_page.dark_theme +def test_external_program_page_propel_career(): + """ + The propel_career property should return expected values if associated with a ExternalProgramPage + """ + external_program_page = ExternalProgramPageFactory.create() + propel_career_page = TextSectionFactory.create( + parent=external_program_page, + content="

content

", + dark_theme=True, + action_title="Action Title", + ) + assert external_program_page.propel_career == propel_career_page + assert propel_career_page.action_title == "Action Title" + assert propel_career_page.content == "

content

" + assert propel_career_page.dark_theme + + def test_is_course_page(): """Returns True if object is type of CoursePage""" program_page = ProgramPageFactory.create() @@ -883,6 +1086,12 @@ def test_is_external_course_page(): assert external_course_page.is_external_course_page +def test_is_external_program_page(): + """Returns True if object is type of ExternalProgramPage""" + external_program_page = ExternalProgramPageFactory.create() + assert external_program_page.is_external_program_page + + def test_featured_product(): """Verify that there will be only one product marked as feature.""" program_page = ProgramPageFactory.create(featured=True) @@ -908,6 +1117,11 @@ def test_featured_product(): assert not another_course_page.featured assert external_course_page.featured + external_program_page = ExternalProgramPageFactory.create(featured=True) + external_course_page.refresh_from_db() + assert not external_course_page.featured + assert external_program_page.featured + def test_certificate_for_course_page(): """ diff --git a/cms/templates/catalog_page.html b/cms/templates/catalog_page.html index 128d5df21..c2ab0a1d3 100644 --- a/cms/templates/catalog_page.html +++ b/cms/templates/catalog_page.html @@ -40,7 +40,7 @@

Explore MIT's courses for game-changing professionals

{% for page in all_pages %} - {% if page.program %} + {% if page.program or page.is_external_program_page %} {% include "partials/catalog_card.html" with courseware_page=page object_type="program" tab="all" %} {% elif page.course or page.is_external_course_page %} {% include "partials/catalog_card.html" with courseware_page=page object_type="course" tab="all"%} diff --git a/cms/templates/partials/card_details_top.html b/cms/templates/partials/card_details_top.html index 5087b3002..9dfa7a3fd 100644 --- a/cms/templates/partials/card_details_top.html +++ b/cms/templates/partials/card_details_top.html @@ -16,7 +16,7 @@

    - {% if courseware_page.is_external_course_page and courseware_page.price %} + {% if courseware_page.is_external_page and courseware_page.price %}
  • Price: ${{ courseware_page.price|floatformat:"0"|intcomma }} @@ -35,7 +35,11 @@

    {% if object_type == "program" %}
  • Number of Courses: - {{ courseware_page.product.courses.count }} + {% if courseware_page.is_external_program_page %} + {{ courseware_page.course_count }} + {% else %} + {{ courseware_page.product.courses.count }} + {% endif %}
  • {% endif %} {% if courseware_page.duration %} @@ -44,7 +48,7 @@

    {{ courseware_page.duration }}

  • {% endif %} - {% if courseware_page.is_external_course_page and courseware_page.next_run_date %} + {% if courseware_page.is_external_page and courseware_page.next_run_date %}
  • Next Start Date: {{ courseware_page.next_run_date|date:"F j, Y" }} diff --git a/cms/templates/partials/courseware-carousel-base.html b/cms/templates/partials/courseware-carousel-base.html index 21885d2af..e475e9aeb 100644 --- a/cms/templates/partials/courseware-carousel-base.html +++ b/cms/templates/partials/courseware-carousel-base.html @@ -1,6 +1,6 @@ {% load static wagtailcore_tags image_version_url %} {% for courseware_page in pages %} -
    +
    {% if courseware_page.thumbnail_image %} {{ courseware_page.title }} diff --git a/cms/templates/partials/enroll_button.html b/cms/templates/partials/enroll_button.html index f1199b4d6..051f46232 100644 --- a/cms/templates/partials/enroll_button.html +++ b/cms/templates/partials/enroll_button.html @@ -1,6 +1,6 @@ {% if page.about_mit_xpro.video_url and action_title and action_url %} {{ action_title }} -{% elif page.is_external_course_page %} +{% elif page.is_external_course_page or page.is_external_program_page %} Learn More diff --git a/cms/templates/partials/metadata-tiles.html b/cms/templates/partials/metadata-tiles.html index 18fa40194..bd680175e 100644 --- a/cms/templates/partials/metadata-tiles.html +++ b/cms/templates/partials/metadata-tiles.html @@ -2,7 +2,7 @@
      - {% if page.is_external_course_page and page.next_run_date %} + {% if page.is_external_page and page.next_run_date %}
    • START DATE {{ page.next_run_date|date:"F j, Y" }} @@ -66,7 +66,7 @@ FORMAT Online
    • - {% if page.is_external_course_page and page.price %} + {% if page.is_external_page and page.price %}
    • PRICE ${{ page.price|floatformat:"0"|intcomma }} diff --git a/cms/templates/product_page.html b/cms/templates/product_page.html index 5a14394bc..5b29c6395 100644 --- a/cms/templates/product_page.html +++ b/cms/templates/product_page.html @@ -94,7 +94,9 @@ {% if techniques %} {% include "partials/learning-techniques.html" with page=techniques %} {% endif %} {% if testimonials %} {% include "partials/testimonial-carousel.html" with page=testimonials %} {% endif %} {% if faculty %} {% include "partials/faculty-carousel.html" with page=faculty %} {% endif %} - {% if course_lineup and course_pages %} + {% if page.is_external_program_page and course_lineup%} + {% include "partials/course-carousel.html" with page=course_lineup%} + {% elif course_lineup and course_pages %} {% with program_page=page.program_page %} {% pageurl program_page as program_url %} {% if page.program %}