From 28e6d0471d13f154075f2d3a35fcb2df82932555 Mon Sep 17 00:00:00 2001 From: Ahmed Belal Date: Tue, 28 May 2019 20:23:38 +0500 Subject: [PATCH 1/5] #155 Integrate Wagtail Routing --- cms/management/commands/setup_index_pages.py | 78 ++++ .../0027_course_program_index_pages.py | 52 +++ .../0028_setup_course_program_index_pages.py | 102 +++++ cms/models.py | 172 ++++++- cms/models_test.py | 362 +++++++++++++++ cms/templates/cms/product_page.html | 10 - cms/templates/{cms => }/home_page.html | 0 .../templates/partials/course-carousel.html | 0 .../partials/courseware-carousel-base.html | 0 .../templates/partials/faculty-carousel.html | 6 +- {courses => cms}/templates/partials/faqs.html | 2 +- .../templates/partials/for-teams.html | 0 {courses => cms}/templates/partials/hero.html | 0 .../templates/partials/learning-outcomes.html | 6 +- .../partials/learning-techniques.html | 0 .../templates/partials/metadata-tiles.html | 20 +- cms/templates/partials/subnav.html | 17 + .../templates/partials/target-audience.html | 10 +- .../partials/testimonial-carousel.html | 0 cms/templates/product_page.html | 60 +++ courses/models.py | 144 +----- courses/models_test.py | 422 +----------------- courses/serializers.py | 20 + courses/serializers_test.py | 8 +- courses/templates/catalog.html | 8 +- courses/templates/catalog_card.html | 50 +-- courses/templates/course_detail.html | 55 --- courses/templates/partials/subnav.html | 17 - courses/urls.py | 3 - courses/views.py | 36 +- courses/views_test.py | 25 +- ecommerce/serializers.py | 21 + 32 files changed, 952 insertions(+), 754 deletions(-) create mode 100644 cms/management/commands/setup_index_pages.py create mode 100644 cms/migrations/0027_course_program_index_pages.py create mode 100644 cms/migrations/0028_setup_course_program_index_pages.py delete mode 100644 cms/templates/cms/product_page.html rename cms/templates/{cms => }/home_page.html (100%) rename {courses => cms}/templates/partials/course-carousel.html (100%) rename {courses => cms}/templates/partials/courseware-carousel-base.html (100%) rename {courses => cms}/templates/partials/faculty-carousel.html (79%) rename {courses => cms}/templates/partials/faqs.html (97%) rename {courses => cms}/templates/partials/for-teams.html (100%) rename {courses => cms}/templates/partials/hero.html (100%) rename {courses => cms}/templates/partials/learning-outcomes.html (61%) rename {courses => cms}/templates/partials/learning-techniques.html (100%) rename {courses => cms}/templates/partials/metadata-tiles.html (70%) create mode 100644 cms/templates/partials/subnav.html rename {courses => cms}/templates/partials/target-audience.html (52%) rename {courses => cms}/templates/partials/testimonial-carousel.html (100%) create mode 100644 cms/templates/product_page.html delete mode 100644 courses/templates/course_detail.html delete mode 100644 courses/templates/partials/subnav.html diff --git a/cms/management/commands/setup_index_pages.py b/cms/management/commands/setup_index_pages.py new file mode 100644 index 000000000..fa86578cb --- /dev/null +++ b/cms/management/commands/setup_index_pages.py @@ -0,0 +1,78 @@ +"""Management command to setup courseware index pages""" +from django.core.management.base import BaseCommand +from wagtail.core.models import Site + +from cms.models import CourseIndexPage, CoursePage, ProgramIndexPage, ProgramPage + + +class Command(BaseCommand): + """Creates courseware index pages and moves the existing courseware pages under the index pages""" + + help = "Creates courseware index pages and moves the existing courseware pages under the index pages" + + def add_arguments(self, parser): + parser.add_argument( + "--revert", + action="store_true", + dest="revert", + help="Delete the index pages and move the courseware pages back under the homepage.", + ) + + def handle(self, *args, **options): + """Handle command execution""" + delete = options["revert"] + site = Site.objects.get(is_default_site=True) + if not site: + print( + "No site setup. Please configure a default site before running this command" + ) + return + + if not delete: + course_index = CourseIndexPage.objects.first() + + if not course_index: + course_index = CourseIndexPage(title="Courses") + site.root_page.add_child(instance=course_index) + self.stdout.write(self.style.SUCCESS("Course index page created.")) + + for course_page in CoursePage.objects.all(): + course_page.move(course_index, "last-child") + + self.stdout.write(self.style.SUCCESS("Course pages moved under index.")) + course_index.save_revision().publish() + + program_index = ProgramIndexPage.objects.first() + + if not program_index: + program_index = ProgramIndexPage(title="Programs") + site.root_page.add_child(instance=program_index) + self.stdout.write(self.style.SUCCESS("Program index page created.")) + + for program_page in ProgramPage.objects.all(): + program_page.move(program_index, "last-child") + + self.stdout.write(self.style.SUCCESS("Program pages moved under index.")) + program_index.save_revision().publish() + else: + course_index = CourseIndexPage.objects.first() + if course_index: + for page in course_index.get_children(): + page.move(site.root_page, "last-child") + self.stdout.write( + self.style.SUCCESS("Course pages moved under homepage.") + ) + + course_index.delete() + self.stdout.write(self.style.WARNING("Course index page removed.")) + + program_index = ProgramIndexPage.objects.first() + if program_index: + for page in program_index.get_children(): + page.move(site.root_page, "last-child") + self.stdout.write( + self.style.SUCCESS("Program pages moved under homepage.") + ) + + program_index.delete() + self.stdout.write(self.style.WARNING("Program index page removed.")) diff --git a/cms/migrations/0027_course_program_index_pages.py b/cms/migrations/0027_course_program_index_pages.py new file mode 100644 index 000000000..8cb6201f7 --- /dev/null +++ b/cms/migrations/0027_course_program_index_pages.py @@ -0,0 +1,52 @@ +# Generated by Django 2.1.7 on 2019-05-28 12:40 + +import cms.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("wagtailcore", "0041_group_collection_permissions_verbose_name_plural"), + ("cms", "0026_text_video_section"), + ] + + operations = [ + migrations.CreateModel( + name="CourseIndexPage", + 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", + ), + ) + ], + options={"abstract": False}, + bases=(cms.models.CourseObjectIndexPage, "wagtailcore.page"), + ), + migrations.CreateModel( + name="ProgramIndexPage", + 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", + ), + ) + ], + options={"abstract": False}, + bases=(cms.models.CourseObjectIndexPage, "wagtailcore.page"), + ), + ] diff --git a/cms/migrations/0028_setup_course_program_index_pages.py b/cms/migrations/0028_setup_course_program_index_pages.py new file mode 100644 index 000000000..b88ebf304 --- /dev/null +++ b/cms/migrations/0028_setup_course_program_index_pages.py @@ -0,0 +1,102 @@ +""" +Data migration to ensure the correct state for course/program index pages and +correct depth for course/program detail pages +""" +from django.db import migrations +from wagtail.core.models import Page + +COURSE_INDEX_PAGE_PROPERTIES = dict(title="Courses") +PROGRAM_INDEX_PAGE_PROPERTIES = dict(title="Programs") + + +def delete_wagtail_pages(specific_page_cls, filter_dict=None): + """ + Completely deletes Wagtail CMS pages that match a filter. Wagtail overrides standard delete functionality, + making it difficult to actually delete Page objects and get information about what was deleted. + """ + page_ids_to_delete = specific_page_cls.objects.values_list("id", flat=True) + if filter_dict: + page_ids_to_delete = page_ids_to_delete.filter(**filter_dict) + num_pages = len(page_ids_to_delete) + base_pages_qset = Page.objects.filter(id__in=page_ids_to_delete) + if not base_pages_qset.exists(): + return 0, {} + base_pages_qset.delete() + return ( + num_pages, + {specific_page_cls._meta.label: num_pages}, # pylint: disable=protected-access + ) + + +def get_top_level_wagtail_page(): + """ + The Wagtail CMS (at least in our usage) has one root page at depth 1, and one page at depth 2. All pages that we + create in Wagtail are added as children to the page at depth 2. + """ + return Page.objects.get(depth=2) + + +def create_index_pages_and_nest_detail(apps, schema_editor): + """ + Create index pages for courses and programs and move the respective + course and program pages under these index pages. + """ + from cms.models import CourseIndexPage, ProgramIndexPage + + CoursePage = apps.get_model("cms", "CoursePage") + ProgramPage = apps.get_model("cms", "ProgramPage") + + # Default home page + top_level_page = get_top_level_wagtail_page() + + course_index = CourseIndexPage.objects.first() + if not course_index: + page_obj = CourseIndexPage(**COURSE_INDEX_PAGE_PROPERTIES) + course_index = top_level_page.add_child(instance=page_obj) + program_index = ProgramIndexPage.objects.first() + if not program_index: + page_obj = ProgramIndexPage(**PROGRAM_INDEX_PAGE_PROPERTIES) + program_index = top_level_page.add_child(instance=page_obj) + # Move course/program detail pages to be children of the course/program index pages + for page_id in CoursePage.objects.values_list("id", flat=True): + page = Page.objects.get(id=page_id) + page.move(course_index, "last-child") + for page_id in ProgramPage.objects.values_list("id", flat=True): + page = Page.objects.get(id=page_id) + page.move(program_index, "last-child") + + +def unnest_detail_and_delete_index_pages(apps, schema_editor): + """ + Move course and program pages under the home page and remove index pages. + """ + CourseIndexPage = apps.get_model("cms", "CourseIndexPage") + ProgramIndexPage = apps.get_model("cms", "ProgramIndexPage") + CoursePage = apps.get_model("cms", "CoursePage") + ProgramPage = apps.get_model("cms", "ProgramPage") + + # Move course/program detail pages to be children of the top-level page + top_level_page = get_top_level_wagtail_page() + top_level_child_ids = [child.id for child in top_level_page.get_children()] + for page_id in CoursePage.objects.values_list("id", flat=True): + if page_id not in top_level_child_ids: + page = Page.objects.get(id=page_id) + page.move(top_level_page, "last-child") + for page_id in ProgramPage.objects.values_list("id", flat=True): + if page_id not in top_level_child_ids: + page = Page.objects.get(id=page_id) + page.move(top_level_page, "last-child") + # Remove the course/program index pages + delete_wagtail_pages(ProgramIndexPage) + delete_wagtail_pages(CourseIndexPage) + + +class Migration(migrations.Migration): + + dependencies = [("cms", "0027_course_program_index_pages")] + + operations = [ + migrations.RunPython( + create_index_pages_and_nest_detail, unnest_detail_and_delete_index_pages + ) + ] diff --git a/cms/models.py b/cms/models.py index 4aace09cb..17e7415e5 100644 --- a/cms/models.py +++ b/cms/models.py @@ -4,31 +4,68 @@ from django.db import models from django.utils.text import slugify - +from modelcluster.fields import ParentalKey from wagtail.admin.edit_handlers import ( FieldPanel, + InlinePanel, MultiFieldPanel, StreamFieldPanel, - InlinePanel, ) from wagtail.core import blocks -from wagtail.core.models import Orderable, Page +from wagtail.core.blocks import PageChooserBlock, RawHTMLBlock from wagtail.core.fields import RichTextField, StreamField -from wagtail.core.blocks import RawHTMLBlock, PageChooserBlock -from wagtail.images.models import Image +from wagtail.core.models import Orderable, Page from wagtail.images.blocks import ImageChooserBlock +from wagtail.images.models import Image from wagtail.snippets.models import register_snippet from wagtailmetadata.models import MetadataPageMixin -from modelcluster.fields import ParentalKey - -from mitxpro.views import get_js_settings_context from cms.blocks import ( + FacultyBlock, LearningTechniqueBlock, ResourceBlock, UserTestimonialBlock, - FacultyBlock, ) +from mitxpro.views import get_js_settings_context + + +class CourseObjectIndexPage: + """ + A placeholder class to group courseware object pages as children. + This class logically acts as no more than a "folder" to organize + pages and add parent slug segment to the page url. + """ + + parent_page_types = ["HomePage"] + + @classmethod + def can_create_at(cls, parent): + """ + You can only create one of these pages under the home page. + The parent is limited via the `parent_page_type` list. + """ + return ( + super().can_create_at(parent) + and not parent.get_children().type(cls).exists() + ) + + +class CourseIndexPage(CourseObjectIndexPage, Page): + """ + A placeholder page to group all the courses under it as well + as consequently add /courses/ to the course page urls + """ + + slug = "courses" + + +class ProgramIndexPage(CourseObjectIndexPage, Page): + """ + A placeholder page to group all the programs under it as well + as consequently add /programs/ to the program page urls + """ + + slug = "programs" class CourseProgramChildPage(Page): @@ -370,6 +407,8 @@ class HomePage(MetadataPageMixin, Page): CMS Page representing the home/root route """ + template = "home_page.html" + subhead = models.CharField( max_length=255, help_text="The subhead to display in the hero section on the home page.", @@ -395,8 +434,8 @@ class HomePage(MetadataPageMixin, Page): ] subpage_types = [ - "CoursePage", - "ProgramPage", + "CourseIndexPage", + "ProgramIndexPage", "CoursesInProgramPage", "LearningTechniquesPage", "UserTestimonialsPage", @@ -552,6 +591,7 @@ class Meta: def get_context(self, request, *args, **kwargs): context = super(ProductPage, self).get_context(request) + context.update(**get_js_settings_context(request)) context["title"] = self.title return context @@ -560,13 +600,51 @@ def _get_child_page_of_type(self, cls): child = self.get_children().type(cls).first() return child.specific if child else None + @property + def outcomes(self): + """Gets the learning outcomes child page""" + return self._get_child_page_of_type(LearningOutcomesPage) + + @property + def who_should_enroll(self): + """Gets the who should enroll child page""" + return self._get_child_page_of_type(WhoShouldEnrollPage) + + @property + def techniques(self): + """Gets the learning techniques child page""" + return self._get_child_page_of_type(LearningTechniquesPage) + + @property + def testimonials(self): + """Gets the testimonials carousel child page""" + return self._get_child_page_of_type(UserTestimonialsPage) + + @property + def faculty(self): + """Gets the faculty carousel page""" + return self._get_child_page_of_type(FacultyMembersPage) + + @property + def for_teams(self): + """Gets the for teams section child page""" + return self._get_child_page_of_type(ForTeamsPage) + + @property + def faqs(self): + """Gets the FAQs list from FAQs child page""" + faqs_page = self._get_child_page_of_type(FrequentlyAskedQuestionPage) + return FrequentlyAskedQuestion.objects.filter(faqs_page=faqs_page) + class ProgramPage(ProductPage): """ CMS page representing the a Program """ - template = "cms/product_page.html" + template = "product_page.html" + + parent_page_types = ["ProgramIndexPage"] program = models.OneToOneField( "courses.Program", @@ -577,6 +655,13 @@ class ProgramPage(ProductPage): content_panels = [FieldPanel("program")] + ProductPage.content_panels + @property + def program_page(self): + """ + Just here for uniformity in model API for templates + """ + return self + @property def course_pages(self): """ @@ -585,13 +670,25 @@ def course_pages(self): courses = self.program.courses.all() return CoursePage.objects.filter(course_id__in=courses) + @property + def course_lineup(self): + """Gets the course carousel page""" + return self._get_child_page_of_type(CoursesInProgramPage) + + @property + def product(self): + """Gets the product associated with this page""" + return self.program + class CoursePage(ProductPage): """ CMS page representing a Course """ - template = "cms/product_page.html" + template = "product_page.html" + + parent_page_types = ["CourseIndexPage"] course = models.OneToOneField( "courses.Course", @@ -607,7 +704,54 @@ def program_page(self): """ Gets the program page associated with this course, if it exists """ - return self.course.program.page if self.course and self.course.program else None + return self.course.program.page if self.course.program.page else None + + @property + def course_lineup(self): + """Gets the course carousel page""" + return self.program_page.course_lineup if self.program_page else None + + @property + def course_pages(self): + """ + Gets a list of pages (CoursePage) of all the courses from the associated program + """ + if not self.program_page: + return [] + courses = self.program_page.program.courses.all() + return CoursePage.objects.filter(course_id__in=courses) + + @property + def product(self): + """Gets the product associated with this page""" + return self.course + + def get_context(self, request, *args, **kwargs): + # Hits a circular import at the top of the module + from courses.models import CourseRunEnrollment + + course = self.course + run = course.first_unexpired_run + product = run.products.first() if run else None + product_version = product.latest_version if product else None + is_anonymous = request.user.is_anonymous + enrolled = ( + CourseRunEnrollment.objects.filter(user=request.user, run=run).exists() + if run and not is_anonymous + else False + ) + + return { + **super().get_context(request, **kwargs), + **get_js_settings_context(request), + "courseware_url": run.courseware_url if run else None, + "product_version_id": product_version.id + if (product_version and not is_anonymous) + else None, + "enrolled": enrolled, + "user": request.user, + "course": course, + } class FrequentlyAskedQuestionPage(CourseProgramChildPage): diff --git a/cms/models_test.py b/cms/models_test.py index cfa938ed7..c0d984791 100644 --- a/cms/models_test.py +++ b/cms/models_test.py @@ -14,6 +14,22 @@ CoursePageFactory, TextVideoSectionFactory, ImageCarouselPageFactory, + FacultyMembersPageFactory, + LearningTechniquesPageFactory, + FrequentlyAskedQuestionPageFactory, + FrequentlyAskedQuestionFactory, + LearningOutcomesPageFactory, + WhoShouldEnrollPageFactory, +) + +from cms.models import ( + UserTestimonialsPage, + ForTeamsPage, + CoursesInProgramPage, + FrequentlyAskedQuestionPage, + LearningOutcomesPage, + LearningTechniquesPage, + WhoShouldEnrollPage, ) from courses.factories import CourseFactory @@ -198,3 +214,349 @@ def test_image_carousel_section(): assert image_carousel_page.title == "title" for index, image in enumerate(image_carousel_page.images): assert image.value.title == "image-title-{}".format(index) + + +def test_program_page_faculty_subpage(): + """ + FacultyMembersPage should return expected values if associated with ProgramPage + """ + program_page = ProgramPageFactory.create() + + assert not program_page.faculty + FacultyMembersPageFactory.create( + parent=program_page, members=json.dumps(_get_faculty_members()) + ) + _assert_faculty_members(program_page) + + +def test_course_page_faculty_subpage(): + """ + FacultyMembersPage should return expected values if associated with CoursePage + """ + course_page = CoursePageFactory.create() + + assert not course_page.faculty + FacultyMembersPageFactory.create( + parent=course_page, members=json.dumps(_get_faculty_members()) + ) + _assert_faculty_members(course_page) + + +def _get_faculty_members(): + """Provides a `faculty` property instantiation data""" + return [ + { + "type": "member", + "value": {"name": "Test Faculty", "description": "

description

"}, + }, + { + "type": "member", + "value": {"name": "Test Faculty", "description": "

description

"}, + }, + ] + + +def _assert_faculty_members(obj): + """Verifies `faculty` property returns expected value""" + assert obj.faculty + for block in obj.faculty.members: + assert block.block_type == "member" + assert block.value["name"] == "Test Faculty" + assert block.value["description"].source == "

description

" + + +def test_course_page_testimonials(): + """ + testimonials property should return expected value if associated with a CoursePage + """ + course_page = CoursePageFactory.create() + assert UserTestimonialsPage.can_create_at(course_page) + testimonials_page = UserTestimonialsPageFactory.create( + parent=course_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 course_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_program_page_testimonials(): + """ + testimonials property should return expected value if associated with a ProgramPage + """ + program_page = ProgramPageFactory.create() + assert UserTestimonialsPage.can_create_at(program_page) + testimonials_page = UserTestimonialsPageFactory.create( + parent=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 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_course_page_for_teams(): + """ + The ForTeams property should return expected values if associated with a CoursePage + """ + course_page = CoursePageFactory.create() + assert ForTeamsPage.can_create_at(course_page) + teams_page = ForTeamsPageFactory.create( + parent=course_page, + content="

content

", + switch_layout=True, + dark_theme=True, + action_title="Action Title", + ) + assert course_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_for_teams(): + """ + The ForTeams property should return expected values if associated with a ProgramPage + """ + program_page = ProgramPageFactory.create() + assert ForTeamsPage.can_create_at(program_page) + teams_page = ForTeamsPageFactory.create( + parent=program_page, + content="

content

", + switch_layout=True, + dark_theme=True, + action_title="Action Title", + ) + assert 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 + assert not ForTeamsPage.can_create_at(program_page) + + +def test_program_page_course_lineup(): + """ + course_lineup property should return expected values if associated with a ProgramPage + """ + program_page = ProgramPageFactory.create() + assert CoursesInProgramPage.can_create_at(program_page) + courses_page = CoursesInProgramPageFactory.create( + parent=program_page, heading="heading", body="

body

" + ) + assert program_page.course_lineup == courses_page + assert courses_page.heading == "heading" + assert courses_page.body == "

body

" + + +def test_course_page_faq_property(): + """ Faqs property should return list of faqs related to given CoursePage""" + course_page = CoursePageFactory.create() + assert FrequentlyAskedQuestionPage.can_create_at(course_page) + + faqs_page = FrequentlyAskedQuestionPageFactory.create(parent=course_page) + faq = FrequentlyAskedQuestionFactory.create(faqs_page=faqs_page) + + assert faqs_page.get_parent() is course_page + assert list(course_page.faqs) == [faq] + + +def test_program_page_faq_property(): + """ Faqs property should return list of faqs related to given ProgramPage""" + program_page = ProgramPageFactory.create() + assert FrequentlyAskedQuestionPage.can_create_at(program_page) + + faqs_page = FrequentlyAskedQuestionPageFactory.create(parent=program_page) + faq = FrequentlyAskedQuestionFactory.create(faqs_page=faqs_page) + + assert faqs_page.get_parent() is program_page + assert list(program_page.faqs) == [faq] + + +def test_course_page_properties(): + """ + Wagtail-page-related properties should return expected values + """ + course_page = CoursePageFactory.create( + title="

page title

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

desc

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

title

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

page title

" + assert course_page.subhead == "subhead" + assert course_page.description == "

desc

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

title

" + assert course_page.video_url == "http://test.com/mock.mp4" + assert course_page.background_image.title == "background-image" + + +def test_program_page_properties(): + """ + Wagtail-page-related properties should return expected values if the Wagtail page exists + """ + program_page = ProgramPageFactory.create( + title="

page title

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

desc

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

title

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

page title

" + assert program_page.subhead == "subhead" + assert program_page.description == "

desc

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

title

" + assert program_page.video_url == "http://test.com/mock.mp4" + assert program_page.background_image.title == "background-image" + + +def test_course_page_learning_outcomes(): + """ + CoursePage related LearningOutcomesPage should return expected values if it exists + """ + course_page = CoursePageFactory.create() + + assert course_page.outcomes is None + assert LearningOutcomesPage.can_create_at(course_page) + + learning_outcomes_page = LearningOutcomesPageFactory( + parent=course_page, + heading="heading", + sub_heading="subheading", + outcome_items=json.dumps([{"type": "outcome", "value": "benefit"}]), + ) + assert learning_outcomes_page.get_parent() == course_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 course_page.outcomes == learning_outcomes_page + assert not LearningOutcomesPage.can_create_at(course_page) + + +def test_program_learning_outcomes(): + """ + ProgramPage related LearningOutcomesPage should return expected values if it exists + """ + program_page = ProgramPageFactory.create() + + assert LearningOutcomesPage.can_create_at(program_page) + + learning_outcomes_page = LearningOutcomesPageFactory( + parent=program_page, + heading="heading", + sub_heading="subheading", + outcome_items=json.dumps([{"type": "outcome", "value": "benefit"}]), + ) + assert learning_outcomes_page.get_parent() == 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 program_page.outcomes == learning_outcomes_page + assert not LearningOutcomesPage.can_create_at(program_page) + + +def test_course_page_learning_techniques(): + """ + CoursePage related subpages should return expected values if they exist + CoursePage related LearningTechniquesPage should return expected values if it exists + """ + course_page = CoursePageFactory.create() + + assert LearningTechniquesPage.can_create_at(course_page) + learning_techniques_page = LearningTechniquesPageFactory( + parent=course_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() == course_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_learning_techniques(): + """ + ProgramPage related subpages should return expected values if they exist + ProgramPage related LearningTechniquesPage should return expected values if it exists + """ + program_page = ProgramPageFactory.create( + description="

desc

", duration="1 week" + ) + + assert LearningTechniquesPage.can_create_at(program_page) + learning_techniques_page = LearningTechniquesPageFactory( + parent=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() == 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 + """ + program_page = ProgramPageFactory.create() + + assert WhoShouldEnrollPage.can_create_at(program_page) + who_should_enroll_page = WhoShouldEnrollPageFactory.create( + parent=program_page, + content=json.dumps( + [ + {"type": "item", "value": "

item

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

item

"}, + ] + ), + ) + assert who_should_enroll_page.get_parent() == 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 program_page.who_should_enroll == who_should_enroll_page + assert not WhoShouldEnrollPage.can_create_at(program_page) diff --git a/cms/templates/cms/product_page.html b/cms/templates/cms/product_page.html deleted file mode 100644 index b8bbd4ff8..000000000 --- a/cms/templates/cms/product_page.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "base.html" %} - -{% load wagtailcore_tags %} - -{% block body_class %}template-homepage{% endblock %} - -{% block content %} - Product page for {{ page.title|richtext }} - {{ page.content }} -{% endblock %} \ No newline at end of file diff --git a/cms/templates/cms/home_page.html b/cms/templates/home_page.html similarity index 100% rename from cms/templates/cms/home_page.html rename to cms/templates/home_page.html diff --git a/courses/templates/partials/course-carousel.html b/cms/templates/partials/course-carousel.html similarity index 100% rename from courses/templates/partials/course-carousel.html rename to cms/templates/partials/course-carousel.html diff --git a/courses/templates/partials/courseware-carousel-base.html b/cms/templates/partials/courseware-carousel-base.html similarity index 100% rename from courses/templates/partials/courseware-carousel-base.html rename to cms/templates/partials/courseware-carousel-base.html diff --git a/courses/templates/partials/faculty-carousel.html b/cms/templates/partials/faculty-carousel.html similarity index 79% rename from courses/templates/partials/faculty-carousel.html rename to cms/templates/partials/faculty-carousel.html index 07857b340..ac4513074 100644 --- a/courses/templates/partials/faculty-carousel.html +++ b/cms/templates/partials/faculty-carousel.html @@ -2,11 +2,11 @@
-

{{ course.faculty.heading }}

-

{{ course.faculty.subhead }}

+

{{ page.heading }}

+

{{ page.subhead }}

- {% for member in course.faculty.members %} + {% for member in page.members %}
{% image member.value.image fill-300x300 as faculty_image %} diff --git a/courses/templates/partials/faqs.html b/cms/templates/partials/faqs.html similarity index 97% rename from courses/templates/partials/faqs.html rename to cms/templates/partials/faqs.html index f1967987b..72c5bdcd9 100644 --- a/courses/templates/partials/faqs.html +++ b/cms/templates/partials/faqs.html @@ -7,7 +7,7 @@

Please review the following frequently asked questions before enrolling. Don

    - {% for faq in course.faqs %} + {% for faq in faqs %}
  • {% endif %} - {% if course.time_commitment %} + {% if page.time_commitment %}
  • TIME COMMITMENT - {{ course.time_commitment }} + {{ page.time_commitment }}
  • {% endif %} - {% if course.duration %} + {% if page.duration %}
  • DURATION - {{ course.duration }} + {{ page.duration }}
  • {% endif %}
  • FORMAT Online
  • - {% if course.current_price %} + {% if page.product.current_price %}
  • PRICE - ${{ course.current_price|floatformat:"0" }} + ${{ page.product.current_price|floatformat:"0" }}
  • {% endif %}
diff --git a/cms/templates/partials/subnav.html b/cms/templates/partials/subnav.html new file mode 100644 index 000000000..935d723e6 --- /dev/null +++ b/cms/templates/partials/subnav.html @@ -0,0 +1,17 @@ + diff --git a/courses/templates/partials/target-audience.html b/cms/templates/partials/target-audience.html similarity index 52% rename from courses/templates/partials/target-audience.html rename to cms/templates/partials/target-audience.html index 6fc228d81..18c8e8e39 100644 --- a/courses/templates/partials/target-audience.html +++ b/cms/templates/partials/target-audience.html @@ -2,18 +2,18 @@
-
+

Who Should Enroll

    - {% for item in course.who_should_enroll.content %} + {% for item in page.content %}
  • {{ item }}
  • {% endfor %}
- {% if course.who_should_enroll.image %} -
- {% image course.who_should_enroll.image fill-870x500 %} + {% if page.image %} +
+ {% image page.image fill-870x500 %}
{% endif %}
diff --git a/courses/templates/partials/testimonial-carousel.html b/cms/templates/partials/testimonial-carousel.html similarity index 100% rename from courses/templates/partials/testimonial-carousel.html rename to cms/templates/partials/testimonial-carousel.html diff --git a/cms/templates/product_page.html b/cms/templates/product_page.html new file mode 100644 index 000000000..5bcb84a43 --- /dev/null +++ b/cms/templates/product_page.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% load static wagtailcore_tags wagtailimages_tags wagtailmetadata_tags render_bundle %} + + +{% block title %}MIT xPro | {{ page.title }}{% endblock %} + +{% block seohead %} + {% meta_tags page %} +{% endblock%} + +{% block headercontent %} + + {% render_bundle 'header' %} +{% endblock %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} + +{% block scripts %} + {{ block.super }} + + + + +{% endblock %} + +{% block content %} +
+ {% include "partials/hero.html" %} + {% include "partials/metadata-tiles.html" %} + {% include "partials/subnav.html" with product=page.product %} + {% if page.outcomes %} {% include "partials/learning-outcomes.html" with page=page.outcomes %} {% endif %} + {% if page.who_should_enroll %} {% include "partials/target-audience.html" with page=page.who_should_enroll %} {% endif %} + {% if page.techniques %} {% include "partials/learning-techniques.html" with page=page.techniques %} {% endif %} + {% if page.testimonials %} {% include "partials/testimonial-carousel.html" with page=page.testimonials %} {% endif %} + {% if page.faculty %} {% include "partials/faculty-carousel.html" with page=page.faculty %} {% endif %} + {% if page.course_lineup and page.course_pages %} + {% with program_page=page.program_page courseware_pages=page.course_pages page=page.course_lineup %} + {% pageurl program_page as program_url %} + {% include "partials/course-carousel.html" with button_title="View Full Program" button_url=program_url %} + {% endwith %} + {% endif %} + {% if page.for_teams %} {% include "partials/for-teams.html" with page=page.for_teams %} {% endif %} + {% if page.faqs %} {% include "partials/faqs.html" with faqs=page.faqs %} {% endif %} +
+{% endblock %} diff --git a/courses/models.py b/courses/models.py index 58fe51904..ea51b9332 100644 --- a/courses/models.py +++ b/courses/models.py @@ -6,19 +6,7 @@ from django.db import models from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericRelation -from django.urls import reverse - -from cms.models import ( - LearningOutcomesPage, - LearningTechniquesPage, - FrequentlyAskedQuestion, - FrequentlyAskedQuestionPage, - ForTeamsPage, - WhoShouldEnrollPage, - CoursesInProgramPage, - UserTestimonialsPage, - FacultyMembersPage, -) + from courses.constants import ( CATALOG_COURSE_IMG_WAGTAIL_FILL, COURSE_BG_IMG_WAGTAIL_FILL, @@ -74,26 +62,6 @@ class PageProperties(models.Model): class Meta: abstract = True - @property - def display_title(self): - """Gets the title from the associated Page if it exists""" - return self.page.title if self.page else None - - @property - def subhead(self): - """Gets the subhead from the associated Page if it exists""" - return self.page.subhead if self.page else None - - @property - def background_image(self): - """Gets the background_image from the associated Page if it exists""" - return self.page.background_image if self.page else None - - @property - def thumbnail_image(self): - """Gets the thumbnail_image from the associated Page if it exists""" - return self.page.thumbnail_image if self.page else None - @property def background_image_url(self): """Gets the url for the background image (if that image exists)""" @@ -116,84 +84,11 @@ def background_image_mobile_url(self): def catalog_image_url(self): """Gets the url for the thumbnail image as it appears in the catalog (if that image exists)""" return ( - self.thumbnail_image.get_rendition(CATALOG_COURSE_IMG_WAGTAIL_FILL).url - if self.thumbnail_image + self.page.thumbnail_image.get_rendition(CATALOG_COURSE_IMG_WAGTAIL_FILL).url + if self.page and self.page.thumbnail_image else None ) - @property - def video_title(self): - """Get the video_title from the associated Page if it exists""" - return self.page.video_title if self.page else None - - @property - def video_url(self): - """Gets the video_url from the associated Page if it exists""" - return self.page.video_url if self.page else None - - @property - def description(self): - """Gets the description from the associated Page if it exists""" - return self.page.description if self.page else None - - @property - def duration(self): - """Gets the duration from the associated Page if it exists""" - return self.page.duration if self.page else None - - @property - def time_commitment(self): - """Gets the duration from the associated Page if it exists""" - return self.page.time_commitment if self.page else None - - def _get_child_page_of_type(self, cls): - """Gets the first child page of the given type from the associated Page if it exists""" - if not self.page: - return None - child = self.page.get_children().type(cls).first() - if child: - return child.specific - return None - - @property - def outcomes(self): - """Gets the learning outcomes from the associated Page children if it exists""" - return self._get_child_page_of_type(LearningOutcomesPage) - - @property - def for_teams(self): - """Gets the ForTeams associated child page from the associate Page if it exists""" - return self._get_child_page_of_type(ForTeamsPage) - - @property - def techniques(self): - """Gets the learning techniques from the associated Page children if it exists""" - return self._get_child_page_of_type(LearningTechniquesPage) - - @property - def faculty(self): - """Gets the faculty members from the associated child page if it exists""" - return self._get_child_page_of_type(FacultyMembersPage) - - @property - def faqs(self): - """Gets the faqs related to product if exists.""" - if not self.page: - return - - faqs_page = self._get_child_page_of_type(FrequentlyAskedQuestionPage) - return FrequentlyAskedQuestion.objects.filter(faqs_page=faqs_page) - - @property - def testimonials(self): - """Gets the testimonials related to product if they exist""" - return self._get_child_page_of_type(UserTestimonialsPage) - - @property - def who_should_enroll(self): - """Gets the WhoShouldEnroll associated child page from the associated Page if it exists""" - return self._get_child_page_of_type(WhoShouldEnrollPage) - class Program(TimestampedModel, PageProperties): """Model for a course program""" @@ -236,25 +131,6 @@ def current_price(self): return None return latest_version.price - @property - def course_lineup(self): - """Gets the CoursesInProgram subpage if associated with this program""" - return self._get_child_page_of_type(CoursesInProgramPage) - - @property - def url(self): - """ - Gets the URL for this resource - """ - return NotImplementedError() - - @property - def type_name(self): - """ - Gets the descriptive word for the type of this resource - """ - return "program" - def __str__(self): return self.title @@ -317,20 +193,6 @@ def unexpired_runs(self): ) ) - @property - def url(self): - """ - Gets the URL for this resource - """ - return reverse("course-detail", kwargs={"pk": self.pk}) - - @property - def type_name(self): - """ - Gets the descriptive word for the type of this resource - """ - return "course" - class Meta: ordering = ("program", "title") diff --git a/courses/models_test.py b/courses/models_test.py index e2c94afeb..329cf68af 100644 --- a/courses/models_test.py +++ b/courses/models_test.py @@ -1,35 +1,12 @@ """Tests for course models""" from datetime import timedelta -import json -import pytest import factory +import pytest -from courses.factories import ProgramFactory, CourseFactory, CourseRunFactory +from cms.factories import CoursePageFactory, ProgramPageFactory +from courses.factories import CourseFactory, CourseRunFactory, ProgramFactory from ecommerce.factories import ProductFactory, ProductVersionFactory -from cms.factories import ( - ProgramPageFactory, - CoursePageFactory, - LearningOutcomesPageFactory, - LearningTechniquesPageFactory, - FrequentlyAskedQuestionFactory, - FrequentlyAskedQuestionPageFactory, - ForTeamsPageFactory, - WhoShouldEnrollPageFactory, - CoursesInProgramPageFactory, - UserTestimonialsPageFactory, - FacultyMembersPageFactory, -) - -from cms.models import ( - LearningOutcomesPage, - LearningTechniquesPage, - FrequentlyAskedQuestionPage, - ForTeamsPage, - WhoShouldEnrollPage, - CoursesInProgramPage, - UserTestimonialsPage, -) from mitxpro.utils import now_in_utc pytestmark = [pytest.mark.django_db] @@ -100,127 +77,6 @@ def test_program_page(): assert program.page == page -def test_program_page_properties(): - """ - Wagtail-page-related properties should return expected values if the Wagtail page exists - """ - program = ProgramFactory.create() - assert program.description is None - assert program.duration is None - ProgramPageFactory.create( - program=program, - title="

page title

", - subhead="subhead", - description="

desc

", - duration="1 week", - video_title="

title

", - video_url="http://test.com/mock.mp4", - background_image__title="background-image", - ) - assert program.display_title == "

page title

" - assert program.subhead == "subhead" - assert program.description == "

desc

" - assert program.duration == "1 week" - assert program.video_title == "

title

" - assert program.video_url == "http://test.com/mock.mp4" - assert program.background_image.title == "background-image" - - -def test_program_learning_outcomes(): - """ - ProgramPage related LearningOutcomesPage should return expected values if it exists - """ - program = ProgramFactory.create() - program_page = ProgramPageFactory.create(program=program) - - assert program.outcomes is None - assert LearningOutcomesPage.can_create_at(program_page) - - learning_outcomes_page = LearningOutcomesPageFactory( - parent=program_page, - heading="heading", - sub_heading="subheading", - outcome_items=json.dumps([{"type": "outcome", "value": "benefit"}]), - ) - assert learning_outcomes_page.get_parent() == 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 program.outcomes == learning_outcomes_page - assert not LearningOutcomesPage.can_create_at(program_page) - - -def test_program_who_should_enroll(): - """ - ProgramPage related WhoShouldEnrollPage should return expected values if it exists - """ - program = ProgramFactory.create() - program_page = ProgramPageFactory.create(program=program) - - assert program.who_should_enroll is None - assert WhoShouldEnrollPage.can_create_at(program_page) - who_should_enroll_page = WhoShouldEnrollPageFactory.create( - parent=program_page, - content=json.dumps( - [ - {"type": "item", "value": "

item

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

item

"}, - ] - ), - ) - assert who_should_enroll_page.get_parent() == 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 program.who_should_enroll == who_should_enroll_page - assert not WhoShouldEnrollPage.can_create_at(program_page) - - -def test_program_learning_techniques(): - """ - ProgramPage related subpages should return expected values if they exist - ProgramPage related LearningTechniquesPage should return expected values if it exists - """ - program = ProgramFactory.create() - - program_page = ProgramPageFactory.create( - program=program, description="

desc

", duration="1 week" - ) - - assert LearningTechniquesPage.can_create_at(program_page) - learning_techniques_page = LearningTechniquesPageFactory( - parent=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() == 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_faculty_subpage(): - """ - FacultyMembersPage should return expected values if associated with program - """ - program = ProgramFactory.create() - program_page = ProgramPageFactory.create(program=program) - - assert not program.faculty - FacultyMembersPageFactory.create( - parent=program_page, members=json.dumps(_get_faculty_members()) - ) - _assert_faculty_members(program) - - def test_courseware_url(settings): """Test that the courseware_url property yields the correct values""" settings.OPENEDX_BASE_REDIRECT_URL = "http://example.com" @@ -343,278 +199,6 @@ def test_course_page(): assert course.page == page -def test_course_page_properties(): - """ - Wagtail-page-related properties should return expected values if the Wagtail page exists - """ - course = CourseFactory.create() - assert course.display_title is None - assert course.subhead is None - assert course.description is None - assert course.duration is None - assert course.video_title is None - assert course.video_url is None - assert course.background_image is None - assert course.background_image_url is None - assert course.background_image_mobile_url is None - CoursePageFactory.create( - course=course, - title="

page title

", - subhead="subhead", - description="

desc

", - duration="1 week", - video_title="

title

", - video_url="http://test.com/mock.mp4", - background_image__title="background-image", - ) - assert course.display_title == "

page title

" - assert course.subhead == "subhead" - assert course.description == "

desc

" - assert course.duration == "1 week" - assert course.video_title == "

title

" - assert course.video_url == "http://test.com/mock.mp4" - assert course.background_image.title == "background-image" - - -def test_course_learning_outcomes(): - """ - CoursePage related LearningOutcomesPage should return expected values if it exists - """ - course = CourseFactory.create() - course_page = CoursePageFactory.create(course=course) - - assert course.outcomes is None - assert LearningOutcomesPage.can_create_at(course_page) - - learning_outcomes_page = LearningOutcomesPageFactory( - parent=course_page, - heading="heading", - sub_heading="subheading", - outcome_items=json.dumps([{"type": "outcome", "value": "benefit"}]), - ) - assert learning_outcomes_page.get_parent() == course_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 course.outcomes == learning_outcomes_page - assert not LearningOutcomesPage.can_create_at(course_page) - - -def test_course_learning_techniques(): - """ - CoursePage related subpages should return expected values if they exist - CoursePage related LearningTechniquesPage should return expected values if it exists - """ - course = CourseFactory.create() - - course_page = CoursePageFactory.create(course=course) - - assert LearningTechniquesPage.can_create_at(course_page) - learning_techniques_page = LearningTechniquesPageFactory( - parent=course_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() == course_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_course_page_faq_property(): - """ Faqs property should return list of faqs related to given course.""" - course = CourseFactory.create() - assert course.faqs is None - - course_page = CoursePageFactory.create(course=course) - assert FrequentlyAskedQuestionPage.can_create_at(course_page) - - faqs_page = FrequentlyAskedQuestionPageFactory.create(parent=course_page) - faq = FrequentlyAskedQuestionFactory.create(faqs_page=faqs_page) - - assert faqs_page.get_parent() is course_page - assert list(course.faqs) == [faq] - - -def test_program_page_faq_property(): - """ Faqs property should return list of faqs related to given program.""" - program = ProgramFactory.create() - assert program.faqs is None - - program_page = ProgramPageFactory.create(program=program) - assert FrequentlyAskedQuestionPage.can_create_at(program_page) - - faqs_page = FrequentlyAskedQuestionPageFactory.create(parent=program_page) - faq = FrequentlyAskedQuestionFactory.create(faqs_page=faqs_page) - - assert faqs_page.get_parent() is program_page - assert list(program.faqs) == [faq] - - -def test_course_for_teams(): - """ - The ForTeams property should return expected values if associated with a course - """ - course = CourseFactory.create() - assert course.for_teams is None - - course_page = CoursePageFactory.create(course=course) - assert ForTeamsPage.can_create_at(course_page) - teams_page = ForTeamsPageFactory.create( - parent=course_page, - content="

content

", - switch_layout=True, - dark_theme=True, - action_title="Action Title", - ) - assert course.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_for_teams(): - """ - The ForTeams property should return expected values if associated with a program - """ - program = ProgramFactory.create() - assert program.for_teams is None - - program_page = ProgramPageFactory.create(program=program) - assert ForTeamsPage.can_create_at(program_page) - teams_page = ForTeamsPageFactory.create( - parent=program_page, - content="

content

", - switch_layout=True, - dark_theme=True, - action_title="Action Title", - ) - assert program.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 - assert not ForTeamsPage.can_create_at(program_page) - - -def test_program_course_lineup(): - """ - course_lineup property should return expected values if associated with a program - """ - program = ProgramFactory.create() - assert program.course_lineup is None - - program_page = ProgramPageFactory.create(program=program) - assert CoursesInProgramPage.can_create_at(program_page) - courses_page = CoursesInProgramPageFactory.create( - parent=program_page, heading="heading", body="

body

" - ) - assert program.course_lineup == courses_page - assert courses_page.heading == "heading" - assert courses_page.body == "

body

" - - -def test_course_testimonials(): - """ - testimonials property should return expected value if associated with a course - """ - course = CourseFactory.create() - assert course.testimonials is None - - course_page = CoursePageFactory.create(course=course) - assert UserTestimonialsPage.can_create_at(course_page) - testimonials_page = UserTestimonialsPageFactory.create( - parent=course_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 course.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_program_testimonials(): - """ - testimonials property should return expected value if associated with a program - """ - program = ProgramFactory.create() - assert program.testimonials is None - - program_page = ProgramPageFactory.create(program=program) - assert UserTestimonialsPage.can_create_at(program_page) - testimonials_page = UserTestimonialsPageFactory.create( - parent=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 program.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_course_faculty_subpage(): - """ - FacultyMembersPage should return expected values if associated with course - """ - course = CourseFactory.create() - course_page = CoursePageFactory.create(course=course) - - assert not course.faculty - FacultyMembersPageFactory.create( - parent=course_page, members=json.dumps(_get_faculty_members()) - ) - _assert_faculty_members(course) - - -def _get_faculty_members(): - """Provides a `faculty` property instantiation data""" - return [ - { - "type": "member", - "value": {"name": "Test Faculty", "description": "

description

"}, - }, - { - "type": "member", - "value": {"name": "Test Faculty", "description": "

description

"}, - }, - ] - - -def _assert_faculty_members(obj): - """Verifies `faculty` property returns expected value""" - assert obj.faculty - for block in obj.faculty.members: - assert block.block_type == "member" - assert block.value["name"] == "Test Faculty" - assert block.value["description"].source == "

description

" - - def test_course_unexpired_runs(): """unexpired_runs should return expected value""" course = CourseFactory.create() diff --git a/courses/serializers.py b/courses/serializers.py index 61adb348e..740022a3d 100644 --- a/courses/serializers.py +++ b/courses/serializers.py @@ -33,11 +33,16 @@ class BaseCourseSerializer(serializers.ModelSerializer): """Basic course model serializer""" thumbnail_url = serializers.SerializerMethodField() + description = serializers.SerializerMethodField() def get_thumbnail_url(self, instance): """Thumbnail URL""" return _get_thumbnail_url(instance.page) + def get_description(self, instance): + """Description""" + return instance.page.description if instance.page else None + class Meta: model = models.Course fields = ["id", "title", "description", "thumbnail_url", "readable_id"] @@ -64,6 +69,7 @@ class CourseSerializer(serializers.ModelSerializer): """Course model serializer - also serializes child course runs""" thumbnail_url = serializers.SerializerMethodField() + description = serializers.SerializerMethodField() courseruns = CourseRunSerializer(many=True, read_only=True) next_run_id = serializers.SerializerMethodField() @@ -76,6 +82,10 @@ def get_next_run_id(self, instance): run = instance.first_unexpired_run return run.id if run is not None else None + def get_description(self, instance): + """Description""" + return instance.page.description if instance.page else None + class Meta: model = models.Course fields = [ @@ -113,11 +123,16 @@ class BaseProgramSerializer(serializers.ModelSerializer): """Basic program model serializer""" thumbnail_url = serializers.SerializerMethodField() + description = serializers.SerializerMethodField() def get_thumbnail_url(self, instance): """Thumbnail URL""" return _get_thumbnail_url(instance.page) + def get_description(self, instance): + """Description""" + return instance.page.description if instance.page else None + class Meta: model = models.Program fields = ["title", "description", "thumbnail_url", "readable_id", "id"] @@ -127,12 +142,17 @@ class ProgramSerializer(serializers.ModelSerializer): """Program model serializer""" thumbnail_url = serializers.SerializerMethodField() + description = serializers.SerializerMethodField() courses = CourseSerializer(many=True, read_only=True) def get_thumbnail_url(self, instance): """Thumbnail URL""" return _get_thumbnail_url(instance.page) + def get_description(self, instance): + """Description""" + return instance.page.description if instance.page else None + class Meta: model = models.Program fields = [ diff --git a/courses/serializers_test.py b/courses/serializers_test.py index 09f7bad68..5321c5006 100644 --- a/courses/serializers_test.py +++ b/courses/serializers_test.py @@ -39,7 +39,7 @@ def test_base_program_serializer(): "title": program.title, "readable_id": program.readable_id, "id": program.id, - "description": program.description, + "description": page.description, "thumbnail_url": page.thumbnail_image.file.url, } @@ -54,7 +54,7 @@ def test_serialize_program(): "title": program.title, "readable_id": program.readable_id, "id": program.id, - "description": program.description, + "description": page.description, "courses": [CourseSerializer(run.course).data], "thumbnail_url": page.thumbnail_image.file.url, } @@ -68,7 +68,7 @@ def test_base_course_serializer(): data = BaseCourseSerializer(course).data assert data == { "title": course.title, - "description": course.description, + "description": page.description, "readable_id": course.readable_id, "id": course.id, "thumbnail_url": page.thumbnail_image.file.url, @@ -86,7 +86,7 @@ def test_serialize_course(with_runs): data = CourseSerializer(course).data assert data == { "title": course.title, - "description": course.description, + "description": page.description, "readable_id": course.readable_id, "id": course.id, "courseruns": [CourseRunSerializer(run).data] if with_runs else [], diff --git a/courses/templates/catalog.html b/courses/templates/catalog.html index 9d397c63a..6989b929e 100644 --- a/courses/templates/catalog.html +++ b/courses/templates/catalog.html @@ -30,22 +30,22 @@

Explore MIT's courses for game-changing professionals

FEATURED

{% for program in programs %} - {% include "catalog_card.html" with courseware_object=program object_type="program" tab="all" %} + {% include "catalog_card.html" with courseware_page=program.page object_type="program" tab="all" %} {% endfor %} {% for course in courses %} - {% include "catalog_card.html" with courseware_object=course object_type="course" tab="all"%} + {% include "catalog_card.html" with courseware_page=course.page object_type="course" tab="all"%} {% endfor %}

PROGRAMS

{% for program in programs %} - {% include "catalog_card.html" with courseware_object=program object_type="program" tab="program" %} + {% include "catalog_card.html" with courseware_page=program.page object_type="program" tab="program" %} {% endfor %}

COURSES

{% for course in courses %} - {% include "catalog_card.html" with courseware_object=course object_type="course" tab="course" %} + {% include "catalog_card.html" with courseware_page=course.page object_type="course" tab="course" %} {% endfor %}
diff --git a/courses/templates/catalog_card.html b/courses/templates/catalog_card.html index 72cdcf15a..1f1e612e5 100644 --- a/courses/templates/catalog_card.html +++ b/courses/templates/catalog_card.html @@ -1,10 +1,10 @@ -{% load static humanize wagtailimages_tags %} -
+{% load static humanize wagtailcore_tags wagtailimages_tags %} +
- {% if courseware_object.thumbnail_image %} - {% image courseware_object.thumbnail_image fill-335x203 %} + {% if courseware_page.thumbnail_image %} + {% image courseware_page.thumbnail_image fill-335x203 %} {% else %} Preview image {% endif %} @@ -13,36 +13,36 @@

{% if object_type == "course" %} - - {{ courseware_object.title }} + + {{ courseware_page.title }} {% else %} - {{ courseware_object.title }} + {{ courseware_page.title }} {% endif %}

    - {% if courseware_object.current_price %} + {% if courseware_page.product.current_price %}
  • Cost: - ${{ courseware_object.current_price|floatformat:"0"|intcomma }} + ${{ courseware_page.product.current_price|floatformat:"0"|intcomma }}
  • {% endif %} {% if object_type == "program" %}
  • Number of Courses: - {{ courseware_object.courses.count }} + {{ courseware_page.product.courses.count }}
  • {% endif %} - {% if courseware_object.duration %} + {% if courseware_page.duration %}
  • Duration: - {{ courseware_object.duration }} + {{ courseware_page.duration }}
  • {% endif %} - {% if courseware_object.next_run_date %} + {% if courseware_page.product.next_run_date %}
  • Next Start Date: - {{ courseware_object.next_run_date|date:"F j, Y" }} + {{ courseware_page.product.next_run_date|date:"F j, Y" }}
  • {% endif %}
@@ -62,39 +62,39 @@

{% if tab == "all" %} - -