diff --git a/.env.example b/.env.example index 8c887b0f2..9b9e77c89 100644 --- a/.env.example +++ b/.env.example @@ -40,7 +40,7 @@ HUBSPOT_ID_PREFIX= MITOL_DIGITAL_CREDENTIALS_VERIFY_SERVICE_BASE_URL=http://host.docker.internal:5000/ MITOL_DIGITAL_CREDENTIALS_HMAC_SECRET=test-hmac-secret # pragma: allowlist secret -EMERITUS_API_KEY=fake_api_key +EXTERNAL_COURSE_SYNC_API_KEY=fake_api_key POSTHOG_PROJECT_API_KEY= POSTHOG_API_HOST=https://app.posthog.com/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16420b71c..bb741893b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,7 @@ jobs: DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres # pragma: allowlist secret WEBPACK_DISABLE_LOADER_STATS: "True" ELASTICSEARCH_URL: localhost:9200 - EMERITUS_API_KEY: fake_emeritus_api_key # pragma: allowlist secret + EXTERNAL_COURSE_SYNC_API_KEY: fake_external_course_sync_api_key # pragma: allowlist secret MAILGUN_KEY: fake_mailgun_key MAILGUN_SENDER_DOMAIN: other.fake.site MITOL_DIGITAL_CREDENTIALS_VERIFY_SERVICE_BASE_URL: http://localhost:5000 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7d106b2a..aec2db2d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - id: check-toml - id: debug-statements - repo: https://github.com/scop/pre-commit-shfmt - rev: v3.10.0-1 + rev: v3.10.0-2 hooks: - id: shfmt - repo: https://github.com/adrienverge/yamllint.git @@ -51,7 +51,7 @@ repos: - --exclude-files - "_test.js$" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.7.4" + rev: "v0.8.4" hooks: - id: ruff-format - id: ruff diff --git a/RELEASE.rst b/RELEASE.rst index 14b201698..b81550670 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,38 @@ Release Notes ============= +Version 0.166.0 (Released December 16, 2024) +--------------- + +- feat: add Global Alumni in external course sync (#3330) +- [pre-commit.ci] pre-commit autoupdate (#3332) + +Version 0.165.0 (Released December 11, 2024) +--------------- + +- chore: change backend name (#3327) + +Version 0.164.3 (Released December 05, 2024) +--------------- + +- feat: add emeritus api list view (#3329) +- [pre-commit.ci] pre-commit autoupdate (#3326) + +Version 0.164.2 (Released December 02, 2024) +--------------- + +- feat(api): has_prerequisites field added in courses and programs API (#3306) +- Revert "fix(deps): update dependency sass to ~1.81.0" (#3323) +- chore(deps): Remove unused package 'set-value' (#3307) +- perf: select related objects for course and courserun admin (#3316) +- chore(deps): update codecov/codecov-action action to v5 (#3314) +- fix: strip emeritus course title during sync (#3317) +- [pre-commit.ci] pre-commit autoupdate (#3315) +- fix(deps): update dependency sass to ~1.81.0 (#3313) +- chore(deps): update postgres docker tag to v17.1 (#3312) +- chore(deps): update dependency faker to v30.10.0 (#3311) +- fix(deps): update dependency boto3 to v1.35.63 (#3310) + Version 0.164.1 (Released November 21, 2024) --------------- diff --git a/app.json b/app.json index c6e7311f8..c46d189c3 100644 --- a/app.json +++ b/app.json @@ -98,12 +98,12 @@ "description": "'hours' value for the 'generate-course-certificate' scheduled task (defaults to midnight)", "required": false }, - "CRON_EMERITUS_COURSERUN_SYNC_DAYS": { - "description": "'day_of_week' value for 'sync-emeritus-course-runs' scheduled task (default will run once a day).", + "CRON_EXTERNAL_COURSERUN_SYNC_DAYS": { + "description": "'day_of_week' value for 'sync-external-course-runs' scheduled task (default will run once a day).", "required": false }, - "CRON_EMERITUS_COURSERUN_SYNC_HOURS": { - "description": "'hours' value for the 'sync-emeritus-course-runs' scheduled task (defaults to midnight)", + "CRON_EXTERNAL_COURSERUN_SYNC_HOURS": { + "description": "'hours' value for the 'sync-external-course-runs' scheduled task (defaults to midnight)", "required": false }, "CSRF_TRUSTED_ORIGINS": { @@ -234,20 +234,20 @@ "description": "Timeout (in seconds) for requests made via the edX API client", "required": false }, - "EMERITUS_API_BASE_URL": { - "description": "Base API URL for Emeritus API", + "ENROLLMENT_CHANGE_SHEET_ID": { + "description": "ID of the Google Sheet that contains the enrollment change request worksheets (refunds, transfers, etc)", "required": false }, - "EMERITUS_API_KEY": { - "description": "The API Key for Emeritus API", - "required": true - }, - "EMERITUS_API_TIMEOUT": { - "description": "API request timeout for Emeritus APIs in seconds", + "EXTERNAL_COURSE_SYNC_API_BASE_URL": { + "description": "Base API URL for external course sync API", "required": false }, - "ENROLLMENT_CHANGE_SHEET_ID": { - "description": "ID of the Google Sheet that contains the enrollment change request worksheets (refunds, transfers, etc)", + "EXTERNAL_COURSE_SYNC_API_KEY": { + "description": "The API Key for external course sync API", + "required": true + }, + "EXTERNAL_COURSE_SYNC_API_REQUEST_TIMEOUT": { + "description": "API request timeout for external course sync APIs in seconds", "required": false }, "GA_TRACKING_ID": { @@ -482,6 +482,10 @@ "description": "The 'name' value for the Open edX OAuth Application", "required": true }, + "OPENEDX_OAUTH_PROVIDER": { + "description": "Social auth oauth provider backend name", + "required": false + }, "OPENEDX_SERVICE_WORKER_API_TOKEN": { "description": "Active access token with staff level permissions to use with OpenEdX API client for service tasks", "required": false @@ -490,6 +494,10 @@ "description": "Username of the user whose token has been set in OPENEDX_SERVICE_WORKER_API_TOKEN", "required": false }, + "OPENEDX_SOCIAL_LOGIN_PATH": { + "description": "Open edX social auth login url", + "required": false + }, "OPENEDX_TOKEN_EXPIRES_HOURS": { "description": "The number of hours until an access token for the Open edX API expires", "required": false diff --git a/authentication/middleware.py b/authentication/middleware.py index f0d517f32..bc58a756d 100644 --- a/authentication/middleware.py +++ b/authentication/middleware.py @@ -30,7 +30,7 @@ def process_exception(self, request, exception): url = self.get_redirect_uri(request, exception) if url: # noqa: RET503 - url += ("?" in url and "&" or "?") + "message={}&backend={}".format( # noqa: UP032 + url += (("?" in url and "&") or "?") + "message={}&backend={}".format( # noqa: UP032 quote(message), backend_name ) return redirect(url) diff --git a/b2b_ecommerce/api_test.py b/b2b_ecommerce/api_test.py index 373317c3b..e6b099798 100644 --- a/b2b_ecommerce/api_test.py +++ b/b2b_ecommerce/api_test.py @@ -31,7 +31,7 @@ @pytest.fixture(autouse=True) -def cybersource_settings(settings): # noqa: PT004 +def cybersource_settings(settings): """ Set cybersource settings """ diff --git a/b2b_ecommerce/views_test.py b/b2b_ecommerce/views_test.py index 80122326a..fab0bf4ef 100644 --- a/b2b_ecommerce/views_test.py +++ b/b2b_ecommerce/views_test.py @@ -33,7 +33,7 @@ @pytest.fixture(autouse=True) -def ecommerce_settings(settings): # noqa: PT004 +def ecommerce_settings(settings): """ Set cybersource settings """ diff --git a/cms/constants.py b/cms/constants.py index 7d7cf3143..ec99d74d6 100644 --- a/cms/constants.py +++ b/cms/constants.py @@ -9,6 +9,7 @@ WEBINAR_INDEX_SLUG = "webinars" BLOG_INDEX_SLUG = "blog" ENTERPRISE_PAGE_SLUG = "enterprise" +COMMON_COURSEWARE_COMPONENT_INDEX_SLUG = "common-courseware-component-pages" ALL_TOPICS = "All Topics" ALL_TAB = "all-tab" diff --git a/cms/factories.py b/cms/factories.py index 8368ff4ab..6470807cd 100644 --- a/cms/factories.py +++ b/cms/factories.py @@ -15,19 +15,22 @@ SuccessStoriesBlock, UserTestimonialBlock, ) -from cms.constants import UPCOMING_WEBINAR +from cms.constants import COMMON_COURSEWARE_COMPONENT_INDEX_SLUG, UPCOMING_WEBINAR from cms.models import ( BlogIndexPage, CatalogPage, CertificatePage, + CommonComponentIndexPage, CompaniesLogoCarouselSection, CourseIndexPage, + CourseOverviewPage, CoursePage, CoursesInProgramPage, EnterprisePage, ExternalCoursePage, ExternalProgramPage, FacultyMembersPage, + ForTeamsCommonPage, ForTeamsPage, FrequentlyAskedQuestion, FrequentlyAskedQuestionPage, @@ -36,6 +39,7 @@ LearningJourneySection, LearningOutcomesPage, LearningStrategyFormSection, + LearningTechniquesCommonPage, LearningTechniquesPage, NewsAndEventsBlock, NewsAndEventsPage, @@ -52,7 +56,7 @@ WebinarPage, WhoShouldEnrollPage, ) -from courses.factories import CourseFactory, ProgramFactory +from courses.factories import CourseFactory, PlatformFactory, ProgramFactory factory.Faker.add_provider(internet) @@ -576,3 +580,43 @@ class LearningStrategyFormPageFactory(wagtail_factories.PageFactory): class Meta: model = LearningStrategyFormSection + + +class CourseOverviewPageFactory(wagtail_factories.PageFactory): + """CourseOverviewPage factory class""" + + heading = factory.fuzzy.FuzzyText(prefix="heading ") + overview = factory.LazyFunction(lambda: RichText(f"

{FAKE.paragraph()}

")) + + class Meta: + model = CourseOverviewPage + + +class CommonComponentIndexPageFactory(wagtail_factories.PageFactory): + """CommonComponentIndexPage factory class""" + + title = factory.fuzzy.FuzzyText() + slug = COMMON_COURSEWARE_COMPONENT_INDEX_SLUG + + class Meta: + model = CommonComponentIndexPage + + +class LearningTechniqueCommonPageFactory(LearningTechniquesPageFactory): + """LearningTechniquesCommonPage factory class""" + + platform = factory.SubFactory(PlatformFactory) + title = factory.fuzzy.FuzzyText() + + class Meta: + model = LearningTechniquesCommonPage + + +class ForTeamsCommonPageFactory(ForTeamsPageFactory): + """ForTeamsCommonPage factory class""" + + platform = factory.SubFactory(PlatformFactory) + title = factory.fuzzy.FuzzyText() + + class Meta: + model = ForTeamsCommonPage diff --git a/cms/management/commands/create_common_child_page_for_external_courses.py b/cms/management/commands/create_common_child_page_for_external_courses.py new file mode 100644 index 000000000..5d99037ac --- /dev/null +++ b/cms/management/commands/create_common_child_page_for_external_courses.py @@ -0,0 +1,16 @@ +"""Management command to create How You Will Learn and B2B sections in external course pages""" + +from django.core.management.base import BaseCommand + +from cms.models import ExternalCoursePage +from cms.wagtail_hooks import create_common_child_pages_for_external_courses + + +class Command(BaseCommand): + """Backfills How You Will Learn and B2B sections to external course pages""" + + help = __doc__ + + def handle(self, *args, **options): # noqa: ARG002 + for page in ExternalCoursePage.objects.all(): + create_common_child_pages_for_external_courses(None, page) diff --git a/cms/migrations/0073_course_overview_page.py b/cms/migrations/0073_course_overview_page.py new file mode 100644 index 000000000..77b987e84 --- /dev/null +++ b/cms/migrations/0073_course_overview_page.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.16 on 2024-12-04 15:27 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + +import cms.models + + +class Migration(migrations.Migration): + dependencies = [ + ("wagtailcore", "0089_log_entry_data_json_null_to_object"), + ("cms", "0072_add_hybrid_courseware_format_option"), + ] + + operations = [ + migrations.CreateModel( + name="CourseOverviewPage", + 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", + ), + ), + ( + "heading", + models.CharField( + blank=True, + help_text="The Heading to show in this section.", + max_length=255, + null=True, + ), + ), + ( + "overview", + wagtail.fields.RichTextField( + blank=True, + help_text="An overview to provide additional context or information about the course", + null=True, + ), + ), + ], + options={ + "verbose_name": "Course Overview", + }, + bases=(cms.models.DisableSitemapURLMixin, "wagtailcore.page"), + ), + ] diff --git a/cms/migrations/0074_common_component_pages.py b/cms/migrations/0074_common_component_pages.py new file mode 100644 index 000000000..e22e5f902 --- /dev/null +++ b/cms/migrations/0074_common_component_pages.py @@ -0,0 +1,99 @@ +# Generated by Django 4.2.16 on 2024-12-18 09:13 + +import django.db.models.deletion +from django.db import migrations, models + +import cms.models + + +class Migration(migrations.Migration): + dependencies = [ + ("courses", "0041_platform_sync_daily"), + ("wagtailcore", "0089_log_entry_data_json_null_to_object"), + ("cms", "0073_course_overview_page"), + ] + + operations = [ + migrations.CreateModel( + name="CommonComponentIndexPage", + 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.CanCreatePageMixin, + cms.models.DisableSitemapURLMixin, + "wagtailcore.page", + ), + ), + migrations.CreateModel( + name="LearningTechniquesCommonPage", + fields=[ + ( + "learningtechniquespage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="cms.learningtechniquespage", + ), + ), + ( + "platform", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="courses.platform", + ), + ), + ], + options={ + "verbose_name": "Reusable Icon Grid Section for LearningTechniquesPage", + }, + bases=("cms.learningtechniquespage", models.Model), + ), + migrations.CreateModel( + name="ForTeamsCommonPage", + fields=[ + ( + "forteamspage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="cms.forteamspage", + ), + ), + ( + "platform", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="courses.platform", + ), + ), + ], + options={ + "verbose_name": "Reusable Text-Image Section for ForTeamsPage", + }, + bases=("cms.forteamspage", models.Model), + ), + ] diff --git a/cms/migrations/0075_add_min_max_weeks.py b/cms/migrations/0075_add_min_max_weeks.py new file mode 100644 index 000000000..16951b149 --- /dev/null +++ b/cms/migrations/0075_add_min_max_weeks.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.16 on 2024-12-20 11:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0074_common_component_pages"), + ] + + operations = [ + migrations.AddField( + model_name="coursepage", + name="max_weeks", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The maximum number of weeks required to complete the course/program.", + null=True, + ), + ), + migrations.AddField( + model_name="coursepage", + name="min_weeks", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The minimum number of weeks required to complete the course/program.", + null=True, + ), + ), + migrations.AddField( + model_name="externalcoursepage", + name="max_weeks", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The maximum number of weeks required to complete the course/program.", + null=True, + ), + ), + migrations.AddField( + model_name="externalcoursepage", + name="min_weeks", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The minimum number of weeks required to complete the course/program.", + null=True, + ), + ), + migrations.AddField( + model_name="externalprogrampage", + name="max_weeks", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The maximum number of weeks required to complete the course/program.", + null=True, + ), + ), + migrations.AddField( + model_name="externalprogrampage", + name="min_weeks", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The minimum number of weeks required to complete the course/program.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="max_weeks", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The maximum number of weeks required to complete the course/program.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="min_weeks", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The minimum number of weeks required to complete the course/program.", + null=True, + ), + ), + ] diff --git a/cms/migrations/0076_min_max_weekly_hours.py b/cms/migrations/0076_min_max_weekly_hours.py new file mode 100644 index 000000000..b50d95984 --- /dev/null +++ b/cms/migrations/0076_min_max_weekly_hours.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.16 on 2024-12-24 08:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0075_add_min_max_weeks"), + ] + + operations = [ + migrations.AddField( + model_name="coursepage", + name="max_weekly_hours", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The maximum number of hours per week required to complete the course.", + null=True, + ), + ), + migrations.AddField( + model_name="coursepage", + name="min_weekly_hours", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The minimum number of hours per week required to complete the course.", + null=True, + ), + ), + migrations.AddField( + model_name="externalcoursepage", + name="max_weekly_hours", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The maximum number of hours per week required to complete the course.", + null=True, + ), + ), + migrations.AddField( + model_name="externalcoursepage", + name="min_weekly_hours", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The minimum number of hours per week required to complete the course.", + null=True, + ), + ), + migrations.AddField( + model_name="externalprogrampage", + name="max_weekly_hours", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The maximum number of hours per week required to complete the course.", + null=True, + ), + ), + migrations.AddField( + model_name="externalprogrampage", + name="min_weekly_hours", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The minimum number of hours per week required to complete the course.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="max_weekly_hours", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The maximum number of hours per week required to complete the course.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="min_weekly_hours", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="The minimum number of hours per week required to complete the course.", + null=True, + ), + ), + ] diff --git a/cms/models.py b/cms/models.py index ce99a1e85..14310c1aa 100644 --- a/cms/models.py +++ b/cms/models.py @@ -61,6 +61,7 @@ ALL_TOPICS, BLOG_INDEX_SLUG, CERTIFICATE_INDEX_SLUG, + COMMON_COURSEWARE_COMPONENT_INDEX_SLUG, COURSE_INDEX_SLUG, ENTERPRISE_PAGE_SLUG, FORMAT_HYBRID, @@ -83,6 +84,7 @@ Course, CourseRunCertificate, CourseTopic, + Platform, Program, ProgramCertificate, ProgramRun, @@ -831,6 +833,7 @@ class HomePage(RoutablePageMixin, MetadataPageMixin, WagtailCachedPageMixin, Pag "WebinarIndexPage", "BlogIndexPage", "EnterprisePage", + "CommonComponentIndexPage", ] @property @@ -950,6 +953,16 @@ class Meta: blank=True, help_text="A short description indicating how long it takes to complete (e.g. '4 weeks')", ) + min_weeks = models.PositiveSmallIntegerField( + null=True, + blank=True, + help_text="The minimum number of weeks required to complete the course/program.", + ) + max_weeks = models.PositiveSmallIntegerField( + null=True, + blank=True, + help_text="The maximum number of weeks required to complete the course/program.", + ) FORMAT_CHOICES = [ (FORMAT_ONLINE, FORMAT_ONLINE), (FORMAT_HYBRID, FORMAT_HYBRID), @@ -980,6 +993,16 @@ class Meta: blank=True, help_text="A short description indicating about the time commitments.", ) + min_weekly_hours = models.PositiveSmallIntegerField( + null=True, + blank=True, + help_text="The minimum number of hours per week required to complete the course.", + ) + max_weekly_hours = models.PositiveSmallIntegerField( + null=True, + blank=True, + help_text="The maximum number of hours per week required to complete the course.", + ) thumbnail_image = models.ForeignKey( Image, null=True, @@ -1011,8 +1034,12 @@ class Meta: FieldPanel("video_title"), FieldPanel("video_url"), FieldPanel("duration"), + FieldPanel("min_weeks"), + FieldPanel("max_weeks"), FieldPanel("format"), FieldPanel("time_commitment"), + FieldPanel("min_weekly_hours"), + FieldPanel("max_weekly_hours"), FieldPanel("description", classname="full"), FieldPanel("catalog_details", classname="full"), FieldPanel("background_image"), @@ -1033,6 +1060,7 @@ class Meta: "TextSection", "CertificatePage", "NewsAndEventsPage", + "CourseOverviewPage", ] # Matches the standard page path that Wagtail returns for this page type. @@ -1075,6 +1103,7 @@ def get_context(self, request, *args, **kwargs): "propel_career": self.propel_career, "news_and_events": self.news_and_events, "ceus": self.certificate_page.CEUs if self.certificate_page else None, + "course_overview": self.course_overview, } def save(self, clean=True, user=None, log_action=False, **kwargs): # noqa: FBT002 @@ -1138,6 +1167,11 @@ def certificate_page(self): """Gets the certificate child page""" return self._get_child_page_of_type(CertificatePage) + @property + def course_overview(self): + """Gets the course overview child page""" + return self._get_child_page_of_type(CourseOverviewPage) + @property def is_course_page(self): """Gets the product page type, this is used for sorting product pages.""" @@ -2635,3 +2669,155 @@ def get_context(self, request, *args, **kwargs): "HUBSPOT_ENTERPRISE_PAGE_FORM_ID" ), } + + +class CourseOverviewPage(CourseProgramChildPage): + """ + CMS Page representing a "Course Overview" section in course + """ + + heading = models.CharField( # noqa: DJ001 + max_length=255, + help_text="The Heading to show in this section.", + null=True, + blank=True, + ) + + overview = RichTextField( + help_text="An overview to provide additional context or information about the course", + null=True, + blank=True, + ) + + @property + def get_overview(self): + """Returns overview if available otherwise returns course page description""" + return self.overview or self.get_parent().specific.description + + content_panels = [ + FieldPanel("heading"), + FieldPanel("overview"), + ] + + class Meta: + verbose_name = "Course Overview" + + +class CommonComponentIndexPage(CanCreatePageMixin, DisableSitemapURLMixin, Page): + """ + A placeholder class to group CommonChildPages as children. + This class logically acts as no more than a "folder" to organize + pages and add parent slug segment to the page url. + """ + + slug = COMMON_COURSEWARE_COMPONENT_INDEX_SLUG + + parent_page_types = ["HomePage"] + + subpage_types = [ + "ForTeamsCommonPage", + "LearningTechniquesCommonPage", + ] + + # disable promote panels, no need for slug entry, it will be autogenerated + promote_panels = [] + + def serve(self, request, *args, **kwargs): # noqa: ARG002 + """ + For index pages we raise a 404 because these pages do not have a template + of their own and we do not expect a page to available at their slug. + """ + raise Http404 + + +class CommonChildPageMixin(models.Model): + """ + Abstract model for common child pages associated with a platform. + + Attributes: + platform (ForeignKey): Optional reference to a platform for the page. + """ + + platform = models.ForeignKey( + Platform, on_delete=models.SET_NULL, null=True, blank=True + ) + + parent_page_types = [ + "CommonComponentIndexPage", + ] + + class Meta: + abstract = True + + def __str__(self): + return f"{self.title} - {self.platform}" if self.platform else self.title + + def save(self, clean=True, user=None, log_action=False, **kwargs): # noqa: FBT002 + # autogenerate a unique slug so we don't hit a ValidationError + if not self.title: + self.title = self.__class__._meta.verbose_name.title() # noqa: SLF001 + self.slug = slugify(f"{self.get_parent().id}-{self.title}-{self.platform}") + Page.save(self, clean=clean, user=user, log_action=log_action, **kwargs) + + @classmethod + def can_create_at(cls, parent): # noqa: ARG003 + # Overrides base can_create_at from CourseProgramChildPage to allow multiple page creation + # Check overridden clean for better control on uniqueness and error handling + return True + + def clean(self): + """ + Validates the uniqueness of the platform for the current page. + + Raises: + ValidationError: If a page with the same platform already exists. + """ + super().clean() + field_error = {"platform": "Page for this platform already exists."} + if ( + not self.platform + and self.__class__.objects.exclude(pk=self.pk) + .filter(platform__isnull=True) + .exists() + ) or ( + self.__class__.objects.exclude(pk=self.pk) + .filter(platform=self.platform) + .exists() + ): + raise ValidationError(field_error) + + +class ForTeamsCommonPage(CommonChildPageMixin, ForTeamsPage): + """ + Represents a platform-specific "For Teams" (text-image) section. + + This class is used to store common "ForTeamsPage" content that can be reused across multiple pages. + It allows easy duplication and creation of specific `ForTeamsPage` instances in their respective + parent pages whenever needed. + """ + + content_panels = [ + FieldPanel("platform"), + *ForTeamsPage.content_panels, + ] + + class Meta: + verbose_name = "Reusable Text-Image Section for ForTeamsPage" + + +class LearningTechniquesCommonPage(CommonChildPageMixin, LearningTechniquesPage): + """ + Represents a platform-specific "LearningTechniquesPage" (Icon Grid) section. + + This class is used to store common "LearningTechniquesPage" content that can be reused across multiple pages. + It allows easy duplication and creation of specific `LearningTechniquesPage` instances in their respective + parent pages whenever needed. + """ + + content_panels = [ + FieldPanel("platform"), + *LearningTechniquesPage.content_panels, + ] + + class Meta: + verbose_name = "Reusable Icon Grid Section for LearningTechniquesPage" diff --git a/cms/models_test.py b/cms/models_test.py index 119b7374d..7962ef8d1 100644 --- a/cms/models_test.py +++ b/cms/models_test.py @@ -14,6 +14,7 @@ from wagtail.test.utils.form_data import querydict_from_html from cms.constants import ( + COMMON_COURSEWARE_COMPONENT_INDEX_SLUG, FORMAT_HYBRID, FORMAT_ONLINE, FORMAT_OTHER, @@ -25,13 +26,16 @@ ) from cms.factories import ( CertificatePageFactory, + CommonComponentIndexPageFactory, CompaniesLogoCarouselPageFactory, + CourseOverviewPageFactory, CoursePageFactory, CoursesInProgramPageFactory, EnterprisePageFactory, ExternalCoursePageFactory, ExternalProgramPageFactory, FacultyMembersPageFactory, + ForTeamsCommonPageFactory, ForTeamsPageFactory, FrequentlyAskedQuestionFactory, FrequentlyAskedQuestionPageFactory, @@ -40,8 +44,10 @@ LearningJourneyPageFactory, LearningOutcomesPageFactory, LearningStrategyFormPageFactory, + LearningTechniqueCommonPageFactory, LearningTechniquesPageFactory, NewsAndEventsPageFactory, + PlatformFactory, ProgramFactory, ProgramPageFactory, ResourcePageFactory, @@ -57,11 +63,17 @@ ) from cms.models import ( CertificatePage, + CommonComponentIndexPage, + CourseIndexPage, + CourseOverviewPage, CoursesInProgramPage, + ExternalCoursePage, + ForTeamsCommonPage, ForTeamsPage, FrequentlyAskedQuestionPage, LearningJourneySection, LearningOutcomesPage, + LearningTechniquesCommonPage, LearningTechniquesPage, SignatoryPage, UserTestimonialsPage, @@ -534,7 +546,7 @@ def _assert_faculty_members(obj): def test_course_page_testimonials(): """ - testimonials property should return expected value if associated with a CoursePage + Testimonials property should return expected value if associated with a CoursePage """ course_page = CoursePageFactory.create() assert UserTestimonialsPage.can_create_at(course_page) @@ -559,7 +571,7 @@ def test_course_page_testimonials(): def test_external_course_page_testimonials(): """ - testimonials property should return expected value if associated with an ExternalCoursePage + Testimonials property should return expected value if associated with an ExternalCoursePage """ external_course_page = ExternalCoursePageFactory.create() assert UserTestimonialsPage.can_create_at(external_course_page) @@ -584,7 +596,7 @@ def test_external_course_page_testimonials(): def test_program_page_testimonials(): """ - testimonials property should return expected value if associated with a ProgramPage + Testimonials property should return expected value if associated with a ProgramPage """ program_page = ProgramPageFactory.create() assert UserTestimonialsPage.can_create_at(program_page) @@ -609,7 +621,7 @@ def test_program_page_testimonials(): def test_external_program_page_testimonials(): """ - testimonials property should return expected value if associated with an ExternalProgramPage + Testimonials property should return expected value if associated with an ExternalProgramPage """ external_program_page = ExternalProgramPageFactory.create() assert UserTestimonialsPage.can_create_at(external_program_page) @@ -2089,3 +2101,267 @@ def test_certificatepage_saved_no_signatories_external_courseware( resp = superuser_client.post(path, data_to_post) assert resp.status_code == 302 + + +@pytest.mark.parametrize( + "page_klass", + [ + ExternalCoursePageFactory, + CoursePageFactory, + ExternalProgramPageFactory, + ProgramPageFactory, + ], +) +@pytest.mark.parametrize( + ("heading", "overview", "course_description"), + [ + # With heading and overview + ( + "heading", + "

Dummy overview

", + "shouldn't matter description", + ), + # Without overview and course description + ("heading", None, ""), + # Without overview but with course description + ("heading", None, "course description"), + # Without heading + (None, "", "course description"), + ], +) +def test_course_overview_page(page_klass, heading, overview, course_description): + """Test CourseOverview Page""" + expected_overview = overview or course_description + page = page_klass.create(description=course_description) + assert not page.course_overview + assert CourseOverviewPage.can_create_at(page) + overview_page = CourseOverviewPageFactory.create( + parent=page, + heading=heading, + overview=overview, + ) + + # invalidate cached property + del page.child_pages + + assert overview_page.get_parent() == page + assert page.course_overview == overview_page + assert overview_page.heading == heading + assert overview_page.get_overview == expected_overview + + # test that it can be modified + new_heading = "new heading" + new_overview = "new test overview" + overview_page.heading = new_heading + overview_page.overview = new_overview + overview_page.save() + + assert overview_page.get_overview == new_overview + assert overview_page.heading == new_heading + + +def _create_external_course_page(superuser_client, course_id, slug): + """ + Creates and publishes an ExternalCoursePage via the Wagtail admin API. + + Args: + superuser_client (Client): Superuser client to send the API request. + course_id (int): ID of the course to associate with the page. + slug (str): Slug for the new ExternalCoursePage. + + Asserts: + Response status code is 302 (successful redirection). + """ + + post_data = { + "course": course_id, + "title": "Icon Grid #6064", + "subhead": "testing #6064", + "format": "Online", + "content-count": 0, + "slug": slug, + "action-publish": "action-publish", + } + response = superuser_client.post( + reverse( + "wagtailadmin_pages:add", + args=("cms", "externalcoursepage", CourseIndexPage.objects.first().id), + ), + post_data, + ) + assert response.status_code == 302 + + +def _is_common_child_pages_created(external_course_page_slug, course_id): + """ + Validates the creation of static child pages under an ExternalCoursePage. + + Args: + external_course_page_slug (str): The slug of the ExternalCoursePage. + course_id (int): The ID of the associated course. + + Asserts: + - The ExternalCoursePage matches the given course ID. + - At least two child pages exist. + - A `LearningTechniquesPage` and a `ForTeamsPage` are present as child pages. + + Returns: + tuple: The `LearningTechniquesPage` and `ForTeamsPage` child pages. + """ + external_course_page = ExternalCoursePage.objects.get( + slug=external_course_page_slug + ) + assert external_course_page.course.id == course_id + assert len(external_course_page.child_pages) >= 2 + learning_technical_page = ( + external_course_page.get_child_page_of_type_including_draft( + LearningTechniquesPage + ) + ) + for_teams_page = external_course_page.get_child_page_of_type_including_draft( + ForTeamsPage + ) + assert learning_technical_page + assert for_teams_page + + return learning_technical_page, for_teams_page + + +def _create_common_child_pages(platform=None): + """ + Creates static common child pages under a CommonComponentIndexPage. + + Args: + platform (Platform, optional): Optional platform object for customizing + attributes like headings. Defaults to None. + + Returns: + tuple: + - `LearningTechniqueCommonPage` with platform-specific attributes. + - `ForTeamsCommonPage` under the same parent page. + """ + common_component_index = CommonComponentIndexPageFactory.create() + tech_heading = f"{platform.name} - heading" if platform else "heading" + title = ( + f"{platform.name} - Learning tech title" if platform else "Learning tech title" + ) + learning_tech_page = LearningTechniqueCommonPageFactory.create( + platform=platform, + title=title, + technique_items__0__techniques__heading=tech_heading, + technique_items__0__techniques__sub_heading="sub_heading", + technique_items__0__techniques__image__image__title="image-title", + parent=common_component_index, + ) + title = f"{platform.name} - For teams title" if platform else "For teamstitle" + b2b_page = ForTeamsCommonPageFactory.create( + platform=platform, parent=common_component_index + ) + return learning_tech_page, b2b_page + + +def test_common_child_index_page(): + """ + Tests the creation of a CommonComponentIndexPage and its relationship + to a CourseIndexPage. + """ + home_page = HomePageFactory.create() + assert CommonComponentIndexPage.can_create_at(home_page) + common_folder = CommonComponentIndexPageFactory.create( + title="common external course pages" + ) + assert common_folder.slug == COMMON_COURSEWARE_COMPONENT_INDEX_SLUG + assert common_folder.title == "common external course pages" + + +def test_common_child_pages_uniqueness(): + """ + Tests the uniqueness constraint for creating multiple instances of the same page + under a CommonComponentIndexPage. + """ + home_page = HomePageFactory.create() + assert CommonComponentIndexPage.can_create_at(home_page) + common_folder = CommonComponentIndexPageFactory.create() + assert LearningTechniquesCommonPage.can_create_at(common_folder) + tech_page = LearningTechniqueCommonPageFactory.create(parent=common_folder) + + # Check if we can create more instances of same page + assert ForTeamsCommonPage.can_create_at(common_folder) + + ForTeamsCommonPageFactory.create(parent=common_folder) + assert len(common_folder.get_children()) == 2 + + # Shouldn't be able to create 2 instance of same page with same platform + with pytest.raises(ValidationError) as context: + LearningTechniqueCommonPageFactory.create( + parent=common_folder, platform=tech_page.platform + ) + + assert ( + str(context.value) == "{'platform': ['Page for this platform already exists.']}" + ) + + +def test_common_child_page_wo_static_page(superuser_client): + """Tests that an ExternalCoursePage is created without static child pages.""" + external_course_page_slug = "external_course_page" + course = CourseFactory.create() + _create_external_course_page(superuser_client, course.id, external_course_page_slug) + external_course_page = ExternalCoursePage.objects.get( + slug=external_course_page_slug + ) + assert external_course_page.course.id == course.id + assert not external_course_page.get_child_page_of_type_including_draft( + LearningTechniquesPage + ) + assert not external_course_page.get_child_page_of_type_including_draft(ForTeamsPage) + + +@pytest.mark.parametrize( + "with_platform", + [True, False], +) +def test_child_page_with_static_pages(superuser_client, with_platform): + """Tests the creation of an ExternalCoursePage with static child pages.""" + platform = PlatformFactory.create() + course = CourseFactory.create(platform=platform) + learning_tech_page, b2b_page = _create_common_child_pages( + platform if with_platform else None + ) + external_course_page_slug = "external_course_page" + _create_external_course_page(superuser_client, course.id, external_course_page_slug) + + learning_technical_page, for_teams_page = _is_common_child_pages_created( + external_course_page_slug, course.id + ) + + assert learning_technical_page.title == learning_tech_page.title + assert learning_technical_page.technique_items == learning_tech_page.technique_items + assert for_teams_page.title == b2b_page.title + + +def test_child_page_with_static_pages_with_platform(superuser_client): + """ + Tests the creation of an ExternalCoursePage with static child pages, + comparing the results with and without a platform. + """ + platform = PlatformFactory.create() + learning_tech_page_wo_platform, b2b_page_wo_platform = _create_common_child_pages() + learning_tech_page, b2b_page = _create_common_child_pages(platform) + course = CourseFactory.create(platform=platform) + external_course_page_slug = "external_course_page" + _create_external_course_page(superuser_client, course.id, external_course_page_slug) + learning_technical_page, for_teams_page = _is_common_child_pages_created( + external_course_page_slug, course.id + ) + + assert learning_technical_page.title != learning_tech_page_wo_platform.title + assert learning_technical_page.title == learning_tech_page.title + assert ( + learning_technical_page.technique_items + != learning_tech_page_wo_platform.technique_items + ) + assert learning_technical_page.technique_items == learning_tech_page.technique_items + + assert for_teams_page.title != b2b_page_wo_platform.title + assert for_teams_page.title == b2b_page.title diff --git a/cms/templates/partials/course-overview.html b/cms/templates/partials/course-overview.html new file mode 100644 index 000000000..d4a3d1e0f --- /dev/null +++ b/cms/templates/partials/course-overview.html @@ -0,0 +1,11 @@ +{% load static wagtailcore_tags %} +
+
+
+ {% if page.heading %} +

{{ page.heading | safe }}

+ {% endif %} + {{ page.get_overview | richtext }} +
+
+
diff --git a/cms/templates/partials/subnav.html b/cms/templates/partials/subnav.html index 0f8560771..e8246c433 100644 --- a/cms/templates/partials/subnav.html +++ b/cms/templates/partials/subnav.html @@ -13,6 +13,11 @@