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 @@
+ {% if course_overview and course_overview.heading or course_overview.get_overview %}
+ -
+ Overview
+
+ {% endif %}
{% if outcomes %}
-
Dummy overview",
+ "shouldn't matter description",
+ ),
+ # Without overview and course description
+ (None, ""),
+ # Without overview but with course description
+ (None, "course test description"),
+ # With overview and course description
+ ("
Overview
", "shouldn't matter description"),
+ ],
+)
+def test_course_overview_context(client, page_klass, overview, course_description):
+ """Test that course page have expected course_overview in context"""
+ 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="test heading",
+ overview=overview,
+ )
+ resp_page = _get_course_page(client, page.get_url())
+ assert resp_page.course_overview == overview_page
+ assert resp_page.course_overview.get_overview == expected_overview
+ assert resp_page.course_overview.heading == overview_page.heading
+
+ # Test modification
+ new_overview = "new_overview"
+ overview_page.overview = new_overview
+ overview_page.save()
+
+ resp_page = _get_course_page(client, page.get_url())
+ assert resp_page.course_overview.get_overview == new_overview
diff --git a/cms/wagtail_hooks.py b/cms/wagtail_hooks.py
index 809a9c5a6..e33d56655 100644
--- a/cms/wagtail_hooks.py
+++ b/cms/wagtail_hooks.py
@@ -4,6 +4,11 @@
from wagtail import hooks
from wagtail.admin.api.views import PagesAdminAPIViewSet
+from cms.models import ExternalCoursePage
+from cms.utils import (
+ create_and_add_b2b_section,
+ create_and_add_how_you_will_learn_section,
+)
from courses.models import CourseRun, Program
from ecommerce.models import Product, ProductVersion
@@ -73,3 +78,23 @@ def create_product_and_versions_for_courseware_pages(request, page):
ProductVersion.objects.create(
product=product, price=price, description=page.program.text_id
)
+
+
+@hooks.register("after_create_page")
+def create_common_child_pages_for_external_courses(request, page): # noqa: ARG001
+ """
+ Automatically creates static sections ("How You Will Learn" and "For Teams")
+ for newly created ExternalCoursePage instances.
+
+ Args:
+ request: The HTTP request that triggered the page creation.
+ page: The newly created page. Static sections are created only if the page
+ is an instance of `ExternalCoursePage`.
+ """
+ if not isinstance(page, ExternalCoursePage):
+ # We need to create sections only for External Course Pages
+ return
+
+ platform = page.course.platform.name
+ create_and_add_how_you_will_learn_section(page, platform)
+ create_and_add_b2b_section(page, platform)
diff --git a/conftest.py b/conftest.py
index 06ac190c0..e91030dd6 100644
--- a/conftest.py
+++ b/conftest.py
@@ -65,7 +65,7 @@ def pytest_configure(config):
@pytest.fixture(scope="session", autouse=True)
-def clean_up_files(): # noqa: PT004
+def clean_up_files():
"""
Fixture that removes the media root folder after the suite has finished running,
effectively deleting any files that were created by factories over the course of the test suite.
@@ -76,7 +76,7 @@ def clean_up_files(): # noqa: PT004
@pytest.fixture(scope="session")
-def django_db_setup(django_db_setup, django_db_blocker): # noqa: ARG001, PT004
+def django_db_setup(django_db_setup, django_db_blocker): # noqa: ARG001
"""
Creates all the index pages during the tests setup as index pages are required by the factories.
"""
diff --git a/courses/admin.py b/courses/admin.py
index 449e0e02f..e80e94ded 100644
--- a/courses/admin.py
+++ b/courses/admin.py
@@ -92,6 +92,7 @@ class CourseRunAdmin(TimestampedModelAdmin):
)
list_filter = ["live", "course__platform", "course"]
list_select_related = ["course", "course__platform"]
+ autocomplete_fields = ["course"]
formfield_overrides = {
models.CharField: {"widget": TextInput(attrs={"size": "80"})}
diff --git a/courses/management/commands/sync_external_course_runs.py b/courses/management/commands/sync_external_course_runs.py
index 9b84d09c9..31eb43e46 100644
--- a/courses/management/commands/sync_external_course_runs.py
+++ b/courses/management/commands/sync_external_course_runs.py
@@ -2,10 +2,10 @@
from django.core.management.base import BaseCommand
-from courses.sync_external_courses.emeritus_api import (
- EmeritusKeyMap,
- fetch_emeritus_courses,
- update_emeritus_course_runs,
+from courses.sync_external_courses.external_course_sync_api import (
+ EXTERNAL_COURSE_VENDOR_KEYMAPS,
+ fetch_external_courses,
+ update_external_course_runs,
)
from mitxpro import settings
@@ -36,18 +36,19 @@ def handle(self, *args, **options): # noqa: ARG002
return
vendor_name = options["vendor_name"]
- if vendor_name.lower() == EmeritusKeyMap.PLATFORM_NAME.value.lower():
- self.stdout.write(f"Starting course sync for {vendor_name}.")
- emeritus_course_runs = fetch_emeritus_courses()
- stats = update_emeritus_course_runs(emeritus_course_runs)
- self.log_stats(stats)
- self.stdout.write(
- self.style.SUCCESS(
- f"External course sync successful for {vendor_name}."
- )
- )
- else:
+ keymap = EXTERNAL_COURSE_VENDOR_KEYMAPS.get(vendor_name.lower())
+ if not keymap:
self.stdout.write(self.style.ERROR(f"Unknown vendor name {vendor_name}."))
+ return
+
+ self.stdout.write(f"Starting course sync for {vendor_name}.")
+ keymap = keymap()
+ external_course_runs = fetch_external_courses(keymap)
+ stats = update_external_course_runs(external_course_runs, keymap)
+ self.log_stats(stats)
+ self.stdout.write(
+ self.style.SUCCESS(f"External course sync successful for {vendor_name}.")
+ )
def log_stats(self, stats):
"""
diff --git a/courses/migrations/0041_platform_sync_daily.py b/courses/migrations/0041_platform_sync_daily.py
new file mode 100644
index 000000000..28aac4ec0
--- /dev/null
+++ b/courses/migrations/0041_platform_sync_daily.py
@@ -0,0 +1,20 @@
+# Generated by Django 4.2.16 on 2024-12-06 13:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("courses", "0040_alter_courserun_courseware_id"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="platform",
+ name="sync_daily",
+ field=models.BooleanField(
+ default=False,
+ help_text="Select this option to enable daily syncing for external course platforms.",
+ ),
+ ),
+ ]
diff --git a/courses/models.py b/courses/models.py
index 9cbf6cae8..f226f8021 100644
--- a/courses/models.py
+++ b/courses/models.py
@@ -214,6 +214,10 @@ class Platform(TimestampedModel, ValidateOnSaveMixin):
"""
name = models.CharField(max_length=255, unique=True)
+ sync_daily = models.BooleanField(
+ default=False,
+ help_text="Select this option to enable daily syncing for external course platforms.",
+ )
def __str__(self):
return self.name
diff --git a/courses/models_test.py b/courses/models_test.py
index 647524b44..7c13c5697 100644
--- a/courses/models_test.py
+++ b/courses/models_test.py
@@ -26,6 +26,9 @@
ProgramRunFactory,
)
from courses.models import CourseRunEnrollment, limit_to_certificate_pages
+from courses.sync_external_courses.external_course_sync_api import (
+ EMERITUS_PLATFORM_NAME,
+)
from ecommerce.factories import ProductFactory, ProductVersionFactory
from mitxpro.test_utils import format_as_iso8601
from mitxpro.utils import now_in_utc
@@ -206,7 +209,7 @@ def test_external_courseware_marketing_url():
def test_program_page():
"""
- page property should return an associated Wagtail page if one exists
+ Page property should return an associated Wagtail page if one exists
"""
program = ProgramFactory.create(page=None)
assert program.page is None
@@ -549,7 +552,7 @@ def test_course_is_catalog_visible():
def test_course_page():
"""
- page property should return an associated Wagtail page if one exists
+ Page property should return an associated Wagtail page if one exists
"""
course = CourseFactory.create(page=None)
assert course.page is None
@@ -812,7 +815,7 @@ def test_platform_name_is_unique():
"""
Tests that case-insensitive platform name is unique.
"""
- PlatformFactory.create(name="Emeritus")
+ PlatformFactory.create(name=EMERITUS_PLATFORM_NAME)
with pytest.raises(ValidationError):
- PlatformFactory.create(name="emeritus")
+ PlatformFactory.create(name=EMERITUS_PLATFORM_NAME.lower())
diff --git a/courses/serializers.py b/courses/serializers.py
index f2422ccd1..fd4bf281e 100644
--- a/courses/serializers.py
+++ b/courses/serializers.py
@@ -8,6 +8,7 @@
from django.templatetags.static import static
from rest_framework import serializers
+from cms.models import ProductPage
from courses import models
from ecommerce.serializers import CompanySerializer
@@ -96,60 +97,145 @@ class Meta:
]
-class CourseSerializer(serializers.ModelSerializer):
- """Course model serializer - also serializes child course runs"""
+class BaseProductSerializer(serializers.ModelSerializer):
+ """Basic product model serializer"""
url = serializers.SerializerMethodField()
+ description = serializers.SerializerMethodField()
external_marketing_url = serializers.SerializerMethodField()
+ marketing_hubspot_form_id = serializers.SerializerMethodField()
thumbnail_url = serializers.SerializerMethodField()
- description = serializers.SerializerMethodField()
- courseruns = serializers.SerializerMethodField()
- next_run_id = serializers.SerializerMethodField()
- topics = serializers.SerializerMethodField()
time_commitment = serializers.SerializerMethodField()
+ min_weekly_hours = serializers.SerializerMethodField()
+ max_weekly_hours = serializers.SerializerMethodField()
duration = serializers.SerializerMethodField()
+ min_weeks = serializers.SerializerMethodField()
+ max_weeks = serializers.SerializerMethodField()
format = serializers.SerializerMethodField()
video_url = serializers.SerializerMethodField()
credits = serializers.SerializerMethodField()
- platform = serializers.SerializerMethodField()
- marketing_hubspot_form_id = serializers.SerializerMethodField()
+
availability = serializers.SerializerMethodField()
+ prerequisites = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ProductPage
+ fields = [
+ "url",
+ "description",
+ "external_marketing_url",
+ "marketing_hubspot_form_id",
+ "thumbnail_url",
+ "time_commitment",
+ "min_weekly_hours",
+ "max_weekly_hours",
+ "duration",
+ "min_weeks",
+ "max_weeks",
+ "format",
+ "video_url",
+ "credits",
+ "availability",
+ "prerequisites",
+ ]
+
+ def get_prerequisites(self, instance): # noqa: ARG002
+ """Get product prerequisites"""
+
+ # This is a hard coded value because the consumers of the API need this field.
+ # In an ideal situation the prerequisites could be list of products.
+ # Since in xPRO we don't have support for prerequisites
+ # so we will not get/check for prerequisites
+ return []
def get_availability(self, instance): # noqa: ARG002
- """Get course availability"""
+ """Get product availability"""
# This is a hard coded value because the consumers of the API need this field.
# In an ideal situation the availability could be "dated" or "anytime".
- # Since all the courses in xPRO are dated so we will not check for "self paced"
- # courses to determine if the course could be "anytime"
+ # Since all the products in xPRO are dated so we will not check for "self paced"
+ # products to determine if the product could be "anytime"
return "dated"
def get_url(self, instance):
- """Get CMS Page URL for the course"""
+ """Get URL"""
page = instance.page
return page.get_full_url() if page else None
+ def get_description(self, instance):
+ """Description"""
+ return instance.page.description if instance.page else None
+
def get_external_marketing_url(self, instance):
- """Return the external marketing URL for the course that's set in CMS page"""
+ """Return the external marketing URL for this product that's set in CMS page"""
return instance.page.external_marketing_url if instance.page else None
def get_marketing_hubspot_form_id(self, instance):
- """Return the marketing HubSpot form ID associated with the course that's set in CMS page"""
+ """Return the marketing HubSpot form ID associated with the product that's set in CMS page"""
return instance.page.marketing_hubspot_form_id if instance.page else None
def get_thumbnail_url(self, instance):
"""Thumbnail URL"""
return _get_thumbnail_url(instance.page)
+ def get_time_commitment(self, instance):
+ """Returns the time commitment for this product that's set in CMS page"""
+ return instance.page.time_commitment if instance.page else None
+
+ def get_duration(self, instance):
+ """Returns the duration for this product that's set in CMS page"""
+ return instance.page.duration if instance.page else None
+
+ def get_min_weeks(self, instance):
+ """
+ Get the min weeks of the product from the CMS page.
+ """
+ return instance.page.min_weeks if instance.page else None
+
+ def get_max_weeks(self, instance):
+ """
+ Get the max weeks of the product from the CMS page.
+ """
+ return instance.page.max_weeks if instance.page else None
+
+ def get_format(self, instance):
+ """Returns the format of the product"""
+ return instance.page.format if instance.page and instance.page.format else None
+
+ def get_video_url(self, instance):
+ """Video URL"""
+ return instance.page.video_url if instance.page else None
+
+ def get_credits(self, instance):
+ """Returns the credits for this product"""
+ return (
+ instance.page.certificate_page.CEUs
+ if instance.page and instance.page.certificate_page
+ else None
+ )
+
+ def get_min_weekly_hours(self, instance):
+ """Returns the minimum weekly hours for this product"""
+ return instance.page.min_weekly_hours if instance.page else None
+
+ def get_max_weekly_hours(self, instance):
+ """Returns the maximum weekly hours for this product"""
+ return instance.page.max_weekly_hours if instance.page else None
+
+
+class CourseSerializer(BaseProductSerializer):
+ """Course model serializer - also serializes child course runs"""
+
+ courseruns = serializers.SerializerMethodField()
+ next_run_id = serializers.SerializerMethodField()
+ topics = serializers.SerializerMethodField()
+ platform = serializers.SerializerMethodField()
+
def get_next_run_id(self, instance):
"""Get next run id"""
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
-
def get_courseruns(self, instance):
"""Unexpired and unenrolled course runs"""
all_runs = self.context.get("all_runs", False)
@@ -178,30 +264,6 @@ def get_topics(self, instance):
)
return []
- def get_time_commitment(self, instance):
- """Returns the time commitment for this course that's set in CMS page"""
- return instance.page.time_commitment if instance.page else None
-
- def get_duration(self, instance):
- """Returns the duration for this course that's set in CMS page"""
- return instance.page.duration if instance.page else None
-
- def get_video_url(self, instance):
- """Video URL"""
- return instance.page.video_url if instance.page else None
-
- def get_credits(self, instance):
- """Returns the credits for this Course"""
- return (
- instance.page.certificate_page.CEUs
- if instance.page and instance.page.certificate_page
- else None
- )
-
- def get_format(self, instance):
- """Returns the format of the course"""
- return instance.page.format if instance.page and instance.page.format else None
-
def get_platform(self, instance):
"""Returns the platform name of the course"""
return getattr(instance.platform, "name", None)
@@ -209,25 +271,15 @@ def get_platform(self, instance):
class Meta:
model = models.Course
fields = [
+ *BaseProductSerializer.Meta.fields,
"id",
"title",
- "description",
- "url",
- "external_marketing_url",
- "marketing_hubspot_form_id",
- "thumbnail_url",
"readable_id",
"courseruns",
"next_run_id",
"topics",
- "time_commitment",
- "duration",
- "video_url",
- "format",
- "credits",
"is_external",
"platform",
- "availability",
]
@@ -271,36 +323,16 @@ class Meta:
fields = ["title", "description", "thumbnail_url", "readable_id", "id"]
-class ProgramSerializer(serializers.ModelSerializer):
+class ProgramSerializer(BaseProductSerializer):
"""Program model serializer"""
- thumbnail_url = serializers.SerializerMethodField()
- description = serializers.SerializerMethodField()
courses = serializers.SerializerMethodField()
start_date = serializers.SerializerMethodField()
end_date = serializers.SerializerMethodField()
enrollment_start = serializers.SerializerMethodField()
- url = serializers.SerializerMethodField()
- external_marketing_url = serializers.SerializerMethodField()
- marketing_hubspot_form_id = serializers.SerializerMethodField()
instructors = serializers.SerializerMethodField()
topics = serializers.SerializerMethodField()
- time_commitment = serializers.SerializerMethodField()
- duration = serializers.SerializerMethodField()
- format = serializers.SerializerMethodField()
- video_url = serializers.SerializerMethodField()
- credits = serializers.SerializerMethodField()
platform = serializers.SerializerMethodField()
- availability = serializers.SerializerMethodField()
-
- def get_availability(self, instance): # noqa: ARG002
- """Get program availability"""
-
- # This is a hard coded value because the consumers of the API need this field.
- # In an ideal situation the availability could be "dated" or "anytime".
- # Since all the programs in xPRO are dated so we will not check for "self paced"
- # courses to determine if the course could be "anytime"
- return "dated"
def get_courses(self, instance):
"""Serializer for courses"""
@@ -313,14 +345,6 @@ def get_courses(self, instance):
context={"filter_products": False},
).data
- 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
-
def get_start_date(self, instance):
"""
start_date is the starting date for the earliest live course run for all courses in a program
@@ -352,19 +376,6 @@ def get_enrollment_start(self, instance):
first_unexpired_run = instance.first_unexpired_run
return getattr(first_unexpired_run, "enrollment_start", None)
- def get_url(self, instance):
- """Get URL"""
- page = instance.page
- return page.get_full_url() if page else None
-
- def get_external_marketing_url(self, instance):
- """Return the external marketing URL for this program that's set in CMS page"""
- return instance.page.external_marketing_url if instance.page else None
-
- def get_marketing_hubspot_form_id(self, instance):
- """Return the marketing HubSpot form ID associated with the program that's set in CMS page"""
- return instance.page.marketing_hubspot_form_id if instance.page else None
-
def get_instructors(self, instance):
"""List all instructors who are a part of any course run within a program"""
return instance.instructors
@@ -379,30 +390,6 @@ def get_topics(self, instance):
}
return [{"name": topic} for topic in sorted(topics)]
- def get_time_commitment(self, instance):
- """Returns the time commitment for this program that's set in CMS page"""
- return instance.page.time_commitment if instance.page else None
-
- def get_duration(self, instance):
- """Returns the duration for this course that's set in CMS page"""
- return instance.page.duration if instance.page else None
-
- def get_video_url(self, instance):
- """Video URL"""
- return instance.page.video_url if instance.page else None
-
- def get_credits(self, instance):
- """Returns the credits for this Course"""
- return (
- instance.page.certificate_page.CEUs
- if instance.page and instance.page.certificate_page
- else None
- )
-
- def get_format(self, instance):
- """Returns the format of the program"""
- return instance.page.format if instance.page and instance.page.format else None
-
def get_platform(self, instance):
"""Returns the platform name of the program"""
return getattr(instance.platform, "name", None)
@@ -410,9 +397,8 @@ def get_platform(self, instance):
class Meta:
model = models.Program
fields = [
+ *BaseProductSerializer.Meta.fields,
"title",
- "description",
- "thumbnail_url",
"readable_id",
"current_price",
"id",
@@ -420,19 +406,10 @@ class Meta:
"start_date",
"end_date",
"enrollment_start",
- "url",
- "external_marketing_url",
- "marketing_hubspot_form_id",
"instructors",
"topics",
- "time_commitment",
- "duration",
- "video_url",
- "format",
- "credits",
"is_external",
"platform",
- "availability",
]
diff --git a/courses/serializers_test.py b/courses/serializers_test.py
index e88148053..bcad2e00f 100644
--- a/courses/serializers_test.py
+++ b/courses/serializers_test.py
@@ -54,17 +54,25 @@ def test_base_program_serializer():
@pytest.mark.parametrize("is_external", [True, False])
@pytest.mark.parametrize("program_format", [FORMAT_ONLINE, FORMAT_HYBRID, FORMAT_OTHER])
@pytest.mark.parametrize(
- "duration, time_commitment, video_url, ceus, external_marketing_url, marketing_hubspot_form_id", # noqa: PT006
+ "duration, min_weeks, max_weeks, time_commitment, min_weekly_hours, max_weekly_hours, video_url, ceus, external_marketing_url, marketing_hubspot_form_id", # noqa: PT006
[
(
- "2 Months",
+ "2 weeks",
+ 4,
+ 4,
"2 Hours",
+ 2,
+ 4,
"http://www.testvideourl.com",
"2 Test CEUs",
"https://www.testexternalcourse1.com",
"fb4f5b79-test-4972-92c3-test",
),
(
+ None,
+ None,
+ None,
+ None,
None,
None,
None,
@@ -80,7 +88,11 @@ def test_serialize_program( # noqa: PLR0913
is_external,
program_format,
duration,
+ min_weeks,
+ max_weeks,
time_commitment,
+ min_weekly_hours,
+ max_weekly_hours,
video_url,
ceus,
external_marketing_url,
@@ -92,8 +104,12 @@ def test_serialize_program( # noqa: PLR0913
is_external=is_external,
page__certificate_page__CEUs=ceus,
page__duration=duration,
+ page__min_weeks=min_weeks,
+ page__max_weeks=max_weeks,
page__format=program_format,
page__time_commitment=time_commitment,
+ page__min_weekly_hours=min_weekly_hours,
+ page__max_weekly_hours=max_weekly_hours,
page__video_url=video_url,
page__external_marketing_url=external_marketing_url,
page__marketing_hubspot_form_id=marketing_hubspot_form_id,
@@ -153,7 +169,11 @@ def test_serialize_program( # noqa: PLR0913
"instructors": [{"name": name} for name in faculty_names],
"topics": [{"name": topic.name} for topic in topics],
"time_commitment": time_commitment,
+ "min_weekly_hours": min_weekly_hours,
+ "max_weekly_hours": max_weekly_hours,
"duration": duration,
+ "max_weeks": max_weeks,
+ "min_weeks": min_weeks,
"format": program_format,
"video_url": video_url,
"credits": ceus,
@@ -162,6 +182,7 @@ def test_serialize_program( # noqa: PLR0913
"marketing_hubspot_form_id": marketing_hubspot_form_id,
"platform": program.platform.name,
"availability": "dated",
+ "prerequisites": [],
},
)
assert data["end_date"] != non_live_run.end_date.strftime(datetime_millis_format)
@@ -186,17 +207,25 @@ def test_base_course_serializer():
@pytest.mark.parametrize("course_page", [True, False])
@pytest.mark.parametrize("course_format", [FORMAT_ONLINE, FORMAT_HYBRID, FORMAT_OTHER])
@pytest.mark.parametrize(
- "duration, time_commitment, video_url, ceus, external_marketing_url, marketing_hubspot_form_id", # noqa: PT006
+ "duration, min_weeks, max_weeks, time_commitment, min_weekly_hours, max_weekly_hours, video_url, ceus, external_marketing_url, marketing_hubspot_form_id", # noqa: PT006
[
(
- "2 Months",
+ "2 weeks",
+ 2,
+ 2,
"2 Hours",
+ 2,
+ 4,
"http://www.testvideourl.com",
"2 Test CEUs",
"http://www.testexternalmarketingurl.com",
"fb4f5b79-test-4972-92c3-test",
),
(
+ None,
+ None,
+ None,
+ None,
None,
None,
None,
@@ -214,7 +243,11 @@ def test_serialize_course( # noqa: PLR0913
course_page,
course_format,
duration,
+ min_weeks,
+ max_weeks,
time_commitment,
+ min_weekly_hours,
+ max_weekly_hours,
video_url,
ceus,
external_marketing_url,
@@ -233,7 +266,11 @@ def test_serialize_course( # noqa: PLR0913
course = CourseFactory.create(
is_external=is_external,
page__time_commitment=time_commitment,
+ page__min_weekly_hours=min_weekly_hours,
+ page__max_weekly_hours=max_weekly_hours,
page__duration=duration,
+ page__min_weeks=min_weeks,
+ page__max_weeks=max_weeks,
page__format=course_format,
page__video_url=video_url,
page__certificate_page__CEUs=ceus,
@@ -287,7 +324,11 @@ def test_serialize_course( # noqa: PLR0913
"next_run_id": course.first_unexpired_run.id,
"topics": [{"name": topic}] if course_page else [],
"time_commitment": time_commitment if course_page else None,
+ "min_weekly_hours": min_weekly_hours if course_page else None,
+ "max_weekly_hours": max_weekly_hours if course_page else None,
"duration": duration if course_page else None,
+ "max_weeks": max_weeks if course_page else None,
+ "min_weeks": min_weeks if course_page else None,
"format": course_format if course_page else None,
"video_url": video_url if course_page else None,
"credits": ceus if course_page else None,
@@ -298,6 +339,7 @@ def test_serialize_course( # noqa: PLR0913
),
"platform": course.platform.name,
"availability": "dated",
+ "prerequisites": [],
},
)
diff --git a/courses/sync_external_courses/emeritus_api.py b/courses/sync_external_courses/external_course_sync_api.py
similarity index 56%
rename from courses/sync_external_courses/emeritus_api.py
rename to courses/sync_external_courses/external_course_sync_api.py
index 8d0dee015..13c33c143 100644
--- a/courses/sync_external_courses/emeritus_api.py
+++ b/courses/sync_external_courses/external_course_sync_api.py
@@ -1,4 +1,4 @@
-"""API for Emeritus course sync"""
+"""API for external course sync"""
import json
import logging
@@ -17,46 +17,88 @@
from cms.models import (
CertificatePage,
CourseIndexPage,
+ CourseOverviewPage,
ExternalCoursePage,
LearningOutcomesPage,
WhoShouldEnrollPage,
)
+from cms.wagtail_hooks import create_common_child_pages_for_external_courses
from courses.api import generate_course_readable_id
from courses.models import Course, CourseRun, CourseTopic, Platform
-from courses.sync_external_courses.emeritus_api_client import EmeritusAPIClient
+from courses.sync_external_courses.external_course_sync_api_client import (
+ ExternalCourseSyncAPIClient,
+)
from ecommerce.models import Product, ProductVersion
from mitxpro.utils import clean_url, now_in_utc, strip_datetime
log = logging.getLogger(__name__)
+EMERITUS_PLATFORM_NAME = "Emeritus"
+GLOBAL_ALUMNI_PLATFORM_NAME = "Global Alumni"
+
-class EmeritusKeyMap(Enum):
+class ExternalCourseVendorBaseKeyMap:
"""
- Emeritus course sync keys.
+ Base class for course sync keys with common attributes.
"""
- REPORT_NAMES = ["Batch"]
- PLATFORM_NAME = "Emeritus"
- DATE_FORMAT = "%Y-%m-%d"
- REQUIRED_FIELDS = [
+ date_format = "%Y-%m-%d"
+ required_fields = [
"course_title",
"course_code",
"course_run_code",
"list_currency",
]
- COURSE_PAGE_SUBHEAD = "Delivered in collaboration with Emeritus."
- WHO_SHOULD_ENROLL_PAGE_HEADING = "WHO SHOULD ENROLL"
- LEARNING_OUTCOMES_PAGE_HEADING = "WHAT YOU WILL LEARN"
- LEARNING_OUTCOMES_PAGE_SUBHEAD = (
- "MIT xPRO is collaborating with online education provider Emeritus to "
- "deliver this online course. By clicking LEARN MORE, you will be taken to "
- "a page where you can download the brochure and apply to the program via Emeritus."
- )
+ who_should_enroll_page_heading = "WHO SHOULD ENROLL"
+ learning_outcomes_page_heading = "WHAT YOU WILL LEARN"
+
+ def __init__(self, platform_name, report_names):
+ self.platform_name = platform_name
+ self.report_names = report_names
+
+ @property
+ def course_page_subhead(self):
+ return f"Delivered in collaboration with {self.platform_name}."
+
+ @property
+ def learning_outcomes_page_subhead(self):
+ return (
+ f"MIT xPRO is collaborating with online education provider {self.platform_name} to "
+ "deliver this online course. By clicking LEARN MORE, you will be taken to "
+ "a page where you can download the brochure and apply to the program via "
+ f"{self.platform_name}."
+ )
+
+
+class EmeritusKeyMap(ExternalCourseVendorBaseKeyMap):
+ """
+ Emeritus course sync keys.
+ """
+
+ def __init__(self):
+ super().__init__(platform_name=EMERITUS_PLATFORM_NAME, report_names=["Batch"])
+
+
+class GlobalAlumniKeyMap(ExternalCourseVendorBaseKeyMap):
+ """
+ Global Alumni course sync keys.
+ """
+
+ def __init__(self):
+ super().__init__(
+ platform_name=GLOBAL_ALUMNI_PLATFORM_NAME, report_names=["GA - Batch"]
+ )
+
+EXTERNAL_COURSE_VENDOR_KEYMAPS = {
+ EMERITUS_PLATFORM_NAME.lower(): EmeritusKeyMap,
+ GLOBAL_ALUMNI_PLATFORM_NAME.lower(): GlobalAlumniKeyMap,
+}
-class EmeritusJobStatus(Enum):
+
+class ExternalCourseSyncAPIJobStatus(Enum):
"""
- Status of an Emeritus Job.
+ Status of an External Course API Job.
"""
READY = 3
@@ -64,79 +106,85 @@ class EmeritusJobStatus(Enum):
CANCELLED = 5
-class EmeritusCourse:
+class ExternalCourse:
"""
- Emeritus course object.
+ External course object.
- Parses an Emeritus course JSON to Python object.
+ Parses an External course JSON to Python object.
"""
- def __init__(self, emeritus_course_json):
- program_name = emeritus_course_json.get("program_name", None)
+ def __init__(self, external_course_json, keymap):
+ program_name = external_course_json.get("program_name", None)
self.course_title = program_name.strip() if program_name else None
- self.course_code = emeritus_course_json.get("course_code")
+ self.course_code = external_course_json.get("course_code")
- # Emeritus course code format is `MO-`, where course tag can contain `.`,
+ # External course code format is `-`, where course tag can contain `.`,
# we will replace `.` with `_` to follow the internal readable id format.
self.course_readable_id = generate_course_readable_id(
self.course_code.split("-")[1].replace(".", "_")
)
- self.course_run_code = emeritus_course_json.get("course_run_code")
- self.course_run_tag = generate_emeritus_course_run_tag(self.course_run_code)
+ self.course_run_code = external_course_json.get("course_run_code")
+ self.course_run_tag = generate_external_course_run_tag(self.course_run_code)
self.price = (
- float(emeritus_course_json.get("list_price"))
- if emeritus_course_json.get("list_price")
+ float(external_course_json.get("list_price"))
+ if external_course_json.get("list_price")
else None
)
- self.list_currency = emeritus_course_json.get("list_currency")
+ self.list_currency = external_course_json.get("list_currency")
self.start_date = strip_datetime(
- emeritus_course_json.get("start_date"), EmeritusKeyMap.DATE_FORMAT.value
+ external_course_json.get("start_date"), keymap.date_format
)
end_datetime = strip_datetime(
- emeritus_course_json.get("end_date"), EmeritusKeyMap.DATE_FORMAT.value
+ external_course_json.get("end_date"), keymap.date_format
)
self.end_date = (
end_datetime.replace(hour=23, minute=59) if end_datetime else None
)
- # Emeritus does not allow enrollments after start date.
+ # External Courses does not allow enrollments after start date.
# We set the course run enrollment_end to the start date to
# hide the course run from the course details page.
self.enrollment_end = self.start_date
self.marketing_url = clean_url(
- emeritus_course_json.get("landing_page_url"), remove_query_params=True
+ external_course_json.get("landing_page_url"), remove_query_params=True
)
- total_weeks = int(emeritus_course_json.get("total_weeks"))
+ total_weeks = int(external_course_json.get("total_weeks"))
self.duration = f"{total_weeks} Weeks" if total_weeks != 0 else ""
+ self.min_weeks = total_weeks
+ self.max_weeks = total_weeks
- # Description can be null in Emeritus API data, we cannot store `None` as description is Non-Nullable
+ # Description can be null in External Course API data, we cannot store `None` as description is Non-Nullable
self.description = (
- emeritus_course_json.get("description")
- if emeritus_course_json.get("description")
+ external_course_json.get("description")
+ if external_course_json.get("description")
else ""
)
- self.format = emeritus_course_json.get("format")
- self.category = emeritus_course_json.get("Category", None)
- self.image_name = emeritus_course_json.get("image_name", None)
- self.CEUs = str(emeritus_course_json.get("ceu") or "")
+ self.format = external_course_json.get("format")
+ self.category = external_course_json.get("Category", None)
+ self.image_name = external_course_json.get("image_name", None)
+ self.CEUs = str(external_course_json.get("ceu") or "")
self.learning_outcomes_list = (
- parse_emeritus_data_str(emeritus_course_json.get("learning_outcomes"))
- if emeritus_course_json.get("learning_outcomes")
+ parse_external_course_data_str(
+ external_course_json.get("learning_outcomes")
+ )
+ if external_course_json.get("learning_outcomes")
else []
)
self.who_should_enroll_list = (
- parse_emeritus_data_str(emeritus_course_json.get("program_for"))
- if emeritus_course_json.get("program_for")
+ parse_external_course_data_str(external_course_json.get("program_for"))
+ if external_course_json.get("program_for")
else []
)
- def validate_required_fields(self):
+ def validate_required_fields(self, keymap):
"""
Validates the course data.
+ Args:
+ keymap(ExternalCourseVendorBaseKeyMap): An ExternalCourseVendorBaseKeyMap object
"""
- for field in EmeritusKeyMap.REQUIRED_FIELDS.value:
+ for field in keymap.required_fields:
if not getattr(self, field, None):
log.info(f"Missing required field {field}") # noqa: G004
return False
@@ -160,28 +208,30 @@ def validate_end_date(self):
return self.end_date and now_in_utc() < self.end_date
-def fetch_emeritus_courses():
+def fetch_external_courses(keymap):
"""
- Fetches Emeritus courses data.
+ Fetches external courses data.
+ Args:
+ keymap(ExternalCourseVendorBaseKeyMap): An ExternalCourseVendorBaseKeyMap object
Makes a request to get the list of available queries and then queries the required reports.
"""
end_date = now_in_utc()
start_date = end_date - timedelta(days=1)
- emeritus_api_client = EmeritusAPIClient()
- queries = emeritus_api_client.get_queries_list()
+ external_course_sync_api_client = ExternalCourseSyncAPIClient()
+ queries = external_course_sync_api_client.get_queries_list()
for query in queries: # noqa: RET503
# Check if query is in list of desired reports
- if query["name"] not in EmeritusKeyMap.REPORT_NAMES.value:
+ if query["name"] not in keymap.report_names:
log.info(
"Report: {} not specified for extract...skipping".format(query["name"]) # noqa: G001
)
continue
log.info("Requesting data for {}...".format(query["name"])) # noqa: G001
- query_response = emeritus_api_client.get_query_response(
+ query_response = external_course_sync_api_client.get_query_response(
query["id"], start_date, end_date
)
if "job" in query_response:
@@ -193,17 +243,20 @@ def fetch_emeritus_courses():
f"Job id: {job_id} found... waiting for completion..." # noqa: G004
)
while True:
- job_status = emeritus_api_client.get_job_status(job_id)
- if job_status["job"]["status"] == EmeritusJobStatus.READY.value:
+ job_status = external_course_sync_api_client.get_job_status(job_id)
+ if (
+ job_status["job"]["status"]
+ == ExternalCourseSyncAPIJobStatus.READY.value
+ ):
# If true, the query_result is ready to be collected.
log.info("Job complete... requesting results...")
- query_response = emeritus_api_client.get_query_result(
+ query_response = external_course_sync_api_client.get_query_result(
job_status["job"]["query_result_id"]
)
break
elif job_status["job"]["status"] in [
- EmeritusJobStatus.FAILED.value,
- EmeritusJobStatus.CANCELLED.value,
+ ExternalCourseSyncAPIJobStatus.FAILED.value,
+ ExternalCourseSyncAPIJobStatus.CANCELLED.value,
]:
log.error("Job failed!")
break
@@ -219,20 +272,20 @@ def fetch_emeritus_courses():
log.error("Something unexpected happened!")
-def update_emeritus_course_runs(emeritus_courses): # noqa: C901, PLR0915
+def update_external_course_runs(external_courses, keymap): # noqa: C901, PLR0915
"""
Updates or creates the required course data i.e. Course, CourseRun,
ExternalCoursePage, CourseTopic, WhoShouldEnrollPage, and LearningOutcomesPage
Args:
- emeritus_courses(list[dict]): A list of Emeritus Courses as a dict.
-
+ external_courses(list[dict]): A list of External Courses as a dict.
+ keymap(ExternalCourseVendorBaseKeyMap): An ExternalCourseVendorBaseKeyMap object
Returns:
dict: Stats of all the objects created/updated.
"""
platform, _ = Platform.objects.get_or_create(
- name__iexact=EmeritusKeyMap.PLATFORM_NAME.value,
- defaults={"name": EmeritusKeyMap.PLATFORM_NAME.value},
+ name__iexact=keymap.platform_name,
+ defaults={"name": keymap.platform_name},
)
course_index_page = Page.objects.get(id=CourseIndexPage.objects.first().id).specific
stats = {
@@ -251,83 +304,83 @@ def update_emeritus_course_runs(emeritus_courses): # noqa: C901, PLR0915
"certificates_updated": set(),
}
- for emeritus_course_json in emeritus_courses:
- emeritus_course = EmeritusCourse(emeritus_course_json)
+ for external_course_json in external_courses:
+ external_course = ExternalCourse(external_course_json, keymap)
log.info(
"Creating or updating course metadata for title: {}, course_code: {}, course_run_code: {}".format( # noqa: G001, UP032
- emeritus_course.course_title,
- emeritus_course.course_code,
- emeritus_course.course_run_code,
+ external_course.course_title,
+ external_course.course_code,
+ external_course.course_run_code,
)
)
if (
- not emeritus_course.validate_required_fields()
- or not emeritus_course.validate_list_currency()
+ not external_course.validate_required_fields(keymap)
+ or not external_course.validate_list_currency()
):
log.info(
- f"Skipping due to bad data... Course data: {json.dumps(emeritus_course_json)}" # noqa: G004
+ f"Skipping due to bad data... Course data: {json.dumps(external_course_json)}" # noqa: G004
)
- stats["course_runs_skipped"].add(emeritus_course.course_run_code)
+ stats["course_runs_skipped"].add(external_course.course_run_code)
continue
- if not emeritus_course.validate_end_date():
+ if not external_course.validate_end_date():
log.info(
- f"Course run is expired, Skipping... Course data: {json.dumps(emeritus_course_json)}" # noqa: G004
+ f"Course run is expired, Skipping... Course data: {json.dumps(external_course_json)}" # noqa: G004
)
- stats["course_runs_expired"].add(emeritus_course.course_run_code)
+ stats["course_runs_expired"].add(external_course.course_run_code)
continue
with transaction.atomic():
course, course_created = Course.objects.get_or_create(
- external_course_id=emeritus_course.course_code,
+ external_course_id=external_course.course_code,
platform=platform,
is_external=True,
defaults={
- "title": emeritus_course.course_title,
- "readable_id": emeritus_course.course_readable_id,
+ "title": external_course.course_title,
+ "readable_id": external_course.course_readable_id,
# All new courses are live by default, we will change the status manually
"live": True,
},
)
if course_created:
- stats["courses_created"].add(emeritus_course.course_code)
+ stats["courses_created"].add(external_course.course_code)
log.info(
- f"Created course, title: {emeritus_course.course_title}, readable_id: {emeritus_course.course_readable_id}" # noqa: G004
+ f"Created course, title: {external_course.course_title}, readable_id: {external_course.course_readable_id}" # noqa: G004
)
else:
- stats["existing_courses"].add(emeritus_course.course_code)
+ stats["existing_courses"].add(external_course.course_code)
log.info(
- f"Course already exists, title: {emeritus_course.course_title}, readable_id: {emeritus_course.course_readable_id}" # noqa: G004
+ f"Course already exists, title: {external_course.course_title}, readable_id: {external_course.course_readable_id}" # noqa: G004
)
log.info(
- f"Creating or Updating course run, title: {emeritus_course.course_title}, course_run_code: {emeritus_course.course_run_code}" # noqa: G004
+ f"Creating or Updating course run, title: {external_course.course_title}, course_run_code: {external_course.course_run_code}" # noqa: G004
)
course_run, course_run_created, course_run_updated = (
- create_or_update_emeritus_course_run(course, emeritus_course)
+ create_or_update_external_course_run(course, external_course)
)
if course_run_created:
stats["course_runs_created"].add(course_run.external_course_run_id)
log.info(
- f"Created Course Run, title: {emeritus_course.course_title}, external_course_run_id: {course_run.external_course_run_id}" # noqa: G004
+ f"Created Course Run, title: {external_course.course_title}, external_course_run_id: {course_run.external_course_run_id}" # noqa: G004
)
elif course_run_updated:
stats["course_runs_updated"].add(course_run.external_course_run_id)
log.info(
- f"Updated Course Run, title: {emeritus_course.course_title}, external_course_run_id: {course_run.external_course_run_id}" # noqa: G004
+ f"Updated Course Run, title: {external_course.course_title}, external_course_run_id: {course_run.external_course_run_id}" # noqa: G004
)
log.info(
- f"Creating or Updating Product and Product Version, course run courseware_id: {course_run.external_course_run_id}, Price: {emeritus_course.price}" # noqa: G004
+ f"Creating or Updating Product and Product Version, course run courseware_id: {course_run.external_course_run_id}, Price: {external_course.price}" # noqa: G004
)
- if emeritus_course.price:
+ if external_course.price:
product_created, product_version_created = (
create_or_update_product_and_product_version(
- emeritus_course, course_run
+ external_course, course_run
)
)
if product_created:
@@ -341,69 +394,69 @@ def update_emeritus_course_runs(emeritus_courses): # noqa: C901, PLR0915
course_run.external_course_run_id
)
log.info(
- f"Created Product Version for course run: {course_run.courseware_id}, Price: {emeritus_course.price}" # noqa: G004
+ f"Created Product Version for course run: {course_run.courseware_id}, Price: {external_course.price}" # noqa: G004
)
else:
log.info(
- f"Price is Null for course run code: {emeritus_course.course_run_code}" # noqa: G004
+ f"Price is Null for course run code: {external_course.course_run_code}" # noqa: G004
)
- stats["course_runs_without_prices"].add(emeritus_course.course_run_code)
+ stats["course_runs_without_prices"].add(external_course.course_run_code)
log.info(
- f"Creating or Updating course page, title: {emeritus_course.course_title}, course_code: {emeritus_course.course_run_code}" # noqa: G004
+ f"Creating or Updating course page, title: {external_course.course_title}, course_code: {external_course.course_run_code}" # noqa: G004
)
course_page, course_page_created, course_page_updated = (
- create_or_update_emeritus_course_page(
- course_index_page, course, emeritus_course
+ create_or_update_external_course_page(
+ course_index_page, course, external_course, keymap
)
)
if course_page_created:
- stats["course_pages_created"].add(emeritus_course.course_code)
+ stats["course_pages_created"].add(external_course.course_code)
log.info(
- f"Created external course page for course title: {emeritus_course.course_title}" # noqa: G004
+ f"Created external course page for course title: {external_course.course_title}" # noqa: G004
)
elif course_page_updated:
- stats["course_pages_updated"].add(emeritus_course.course_code)
+ stats["course_pages_updated"].add(external_course.course_code)
log.info(
- f"Updated external course page for course title: {emeritus_course.course_title}" # noqa: G004
+ f"Updated external course page for course title: {external_course.course_title}" # noqa: G004
)
- if emeritus_course.category:
+ if external_course.category:
topic = CourseTopic.objects.filter(
- name__iexact=emeritus_course.category
+ name__iexact=external_course.category
).first()
if topic:
course_page.topics.add(topic)
course_page.save()
log.info(
- f"Added topic {topic.name} for {emeritus_course.course_title}" # noqa: G004
+ f"Added topic {topic.name} for {external_course.course_title}" # noqa: G004
)
outcomes_page = course_page.get_child_page_of_type_including_draft(
LearningOutcomesPage
)
- if not outcomes_page and emeritus_course.learning_outcomes_list:
+ if not outcomes_page and external_course.learning_outcomes_list:
create_learning_outcomes_page(
- course_page, emeritus_course.learning_outcomes_list
+ course_page, external_course.learning_outcomes_list, keymap
)
log.info("Created LearningOutcomesPage.")
who_should_enroll_page = course_page.get_child_page_of_type_including_draft(
WhoShouldEnrollPage
)
- if not who_should_enroll_page and emeritus_course.who_should_enroll_list:
+ if not who_should_enroll_page and external_course.who_should_enroll_list:
create_who_should_enroll_in_page(
- course_page, emeritus_course.who_should_enroll_list
+ course_page, external_course.who_should_enroll_list, keymap
)
log.info("Created WhoShouldEnrollPage.")
- if emeritus_course.CEUs:
+ if external_course.CEUs:
log.info(
- f"Creating or Updating Certificate Page for title: {emeritus_course.course_title}, course_code: {course.readable_id}, CEUs: {emeritus_course.CEUs}" # noqa: G004
+ f"Creating or Updating Certificate Page for title: {external_course.course_title}, course_code: {course.readable_id}, CEUs: {external_course.CEUs}" # noqa: G004
)
_, is_certificatepage_created, is_certificatepage_updated = (
- create_or_update_certificate_page(course_page, emeritus_course)
+ create_or_update_certificate_page(course_page, external_course)
)
if is_certificatepage_created:
@@ -413,6 +466,15 @@ def update_emeritus_course_runs(emeritus_courses): # noqa: C901, PLR0915
stats["certificates_updated"].add(course.readable_id)
log.info("Certificate Page Updated")
+ overview_page = course_page.get_child_page_of_type_including_draft(
+ CourseOverviewPage
+ )
+ if not overview_page and external_course.description:
+ create_course_overview_page(course_page, external_course)
+ log.info("Created CourseOverviewPage.")
+
+ create_common_child_pages_for_external_courses(None, course_page)
+
# As we get the API data for course runs, we can have duplicate course codes in course created and updated,
# so, we are removing the courses created from the updated courses list.
stats["existing_courses"] = stats["existing_courses"].difference(
@@ -424,43 +486,43 @@ def update_emeritus_course_runs(emeritus_courses): # noqa: C901, PLR0915
return stats
-def create_or_update_product_and_product_version(emeritus_course, course_run):
+def create_or_update_product_and_product_version(external_course, course_run):
"""
Creates or Updates Product and Product Version for the course run.
Args:
- emeritus_course(EmeritusCourse): EmeritusCourse object
+ external_course(ExternalCourse): ExternalCourse object
course_run(CourseRun): CourseRun object
Returns:
tuple: (product is created, product version is created)
"""
current_price = course_run.current_price
- if not current_price or current_price != emeritus_course.price:
+ if not current_price or current_price != external_course.price:
product, product_created = Product.objects.get_or_create(
content_type=ContentType.objects.get_for_model(CourseRun),
object_id=course_run.id,
)
ProductVersion.objects.create(
product=product,
- price=emeritus_course.price,
+ price=external_course.price,
description=course_run.courseware_id,
)
return product_created, True
return False, False
-def generate_emeritus_course_run_tag(course_run_code):
+def generate_external_course_run_tag(course_run_code):
"""
- Returns the course run tag generated using the Emeritus Course run code.
+ Returns the course run tag generated using the External Course run code.
- Emeritus course run codes follow a pattern `MO--`. This method returns the run tag.
+ External course run codes follow a pattern `--`. This method returns the run tag.
Args:
- course_run_code(str): Emeritus course code
+ course_run_code(str): External course code
Returns:
- str: Course tag generated from the Emeritus Course Code
+ str: Course tag generated from the External Course Code
"""
run_tag = re.search(r"[0-9]{2}-[0-9]{2}#[0-9]+$", course_run_code).group(0)
return run_tag.replace("#", "-")
@@ -480,14 +542,16 @@ def generate_external_course_run_courseware_id(course_run_tag, course_readable_i
return f"{course_readable_id}+{course_run_tag}"
-def create_or_update_emeritus_course_page(course_index_page, course, emeritus_course):
+def create_or_update_external_course_page( # noqa: C901
+ course_index_page, course, external_course, keymap
+):
"""
- Creates or updates external course page for Emeritus course.
+ Creates or updates external course page for External course.
Args:
course_index_page(CourseIndexPage): A course index page object.
course(Course): A course object.
- emeritus_course(EmeritusCourse): A EmeritusCourse object.
+ external_course(ExternalCourse): A ExternalCourse object.
Returns:
tuple(ExternalCoursePage, is_created, is_updated): ExternalCoursePage object, is_created, is_updated
@@ -497,15 +561,15 @@ def create_or_update_emeritus_course_page(course_index_page, course, emeritus_co
)
image = None
- if emeritus_course.image_name:
+ if external_course.image_name:
image = (
- Image.objects.filter(title=emeritus_course.image_name)
+ Image.objects.filter(title=external_course.image_name)
.order_by("-created_at")
.first()
)
if not image:
- image_title = Path(emeritus_course.image_name).stem
+ image_title = Path(external_course.image_name).stem
image = (
Image.objects.filter(title=image_title).order_by("-created_at").first()
)
@@ -514,12 +578,14 @@ def create_or_update_emeritus_course_page(course_index_page, course, emeritus_co
if not course_page:
course_page = ExternalCoursePage(
course=course,
- title=emeritus_course.course_title,
- external_marketing_url=emeritus_course.marketing_url,
- subhead=EmeritusKeyMap.COURSE_PAGE_SUBHEAD.value,
- duration=emeritus_course.duration,
- format=emeritus_course.format,
- description=emeritus_course.description,
+ title=external_course.course_title,
+ external_marketing_url=external_course.marketing_url,
+ subhead=keymap.course_page_subhead,
+ duration=external_course.duration,
+ min_weeks=external_course.min_weeks,
+ max_weeks=external_course.max_weeks,
+ format=external_course.format,
+ description=external_course.description,
background_image=image,
thumbnail_image=image,
)
@@ -530,16 +596,24 @@ def create_or_update_emeritus_course_page(course_index_page, course, emeritus_co
latest_revision = course_page.get_latest_revision_as_object()
# Only update course page fields with API if they are empty in the latest revision.
- if not latest_revision.external_marketing_url and emeritus_course.marketing_url:
- latest_revision.external_marketing_url = emeritus_course.marketing_url
+ if not latest_revision.external_marketing_url and external_course.marketing_url:
+ latest_revision.external_marketing_url = external_course.marketing_url
+ is_updated = True
+
+ if not latest_revision.duration and external_course.duration:
+ latest_revision.duration = external_course.duration
is_updated = True
- if not latest_revision.duration and emeritus_course.duration:
- latest_revision.duration = emeritus_course.duration
+ if not latest_revision.min_weeks and external_course.min_weeks:
+ latest_revision.min_weeks = external_course.min_weeks
is_updated = True
- if not latest_revision.description and emeritus_course.description:
- latest_revision.description = emeritus_course.description
+ if not latest_revision.max_weeks and external_course.max_weeks:
+ latest_revision.max_weeks = external_course.max_weeks
+ is_updated = True
+
+ if not latest_revision.description and external_course.description:
+ latest_revision.description = external_course.description
is_updated = True
if not latest_revision.background_image and image:
@@ -556,73 +630,73 @@ def create_or_update_emeritus_course_page(course_index_page, course, emeritus_co
return course_page, is_created, is_updated
-def create_or_update_emeritus_course_run(course, emeritus_course):
+def create_or_update_external_course_run(course, external_course):
"""
- Creates or updates the external emeritus course run.
+ Creates or updates the external course run.
Args:
course (courses.Course): Course object
- emeritus_course (EmeritusCourse): EmeritusCourse object
+ external_course (ExternalCourse): ExternalCourse object
Returns:
tuple(CourseRun, is_created, is_updated): A tuple containing course run, is course run created, is course run updated
"""
course_run_courseware_id = generate_external_course_run_courseware_id(
- emeritus_course.course_run_tag, course.readable_id
+ external_course.course_run_tag, course.readable_id
)
course_run = (
CourseRun.objects.select_for_update()
- .filter(external_course_run_id=emeritus_course.course_run_code, course=course)
+ .filter(external_course_run_id=external_course.course_run_code, course=course)
.first()
)
is_created = is_updated = False
if not course_run:
course_run = CourseRun.objects.create(
- external_course_run_id=emeritus_course.course_run_code,
+ external_course_run_id=external_course.course_run_code,
course=course,
- title=emeritus_course.course_title,
+ title=external_course.course_title,
courseware_id=course_run_courseware_id,
- run_tag=emeritus_course.course_run_tag,
- start_date=emeritus_course.start_date,
- end_date=emeritus_course.end_date,
- enrollment_end=emeritus_course.enrollment_end,
+ run_tag=external_course.course_run_tag,
+ start_date=external_course.start_date,
+ end_date=external_course.end_date,
+ enrollment_end=external_course.enrollment_end,
live=True,
)
is_created = True
elif (
- (not course_run.start_date and emeritus_course.start_date)
+ (not course_run.start_date and external_course.start_date)
or (
course_run.start_date
- and emeritus_course.start_date
- and course_run.start_date.date() != emeritus_course.start_date.date()
+ and external_course.start_date
+ and course_run.start_date.date() != external_course.start_date.date()
)
- or (not course_run.end_date and emeritus_course.end_date)
+ or (not course_run.end_date and external_course.end_date)
or (
course_run.end_date
- and emeritus_course.end_date
- and course_run.end_date.date() != emeritus_course.end_date.date()
+ and external_course.end_date
+ and course_run.end_date.date() != external_course.end_date.date()
)
- or (not course_run.enrollment_end and emeritus_course.enrollment_end)
+ or (not course_run.enrollment_end and external_course.enrollment_end)
or (
course_run.enrollment_end
- and emeritus_course.enrollment_end
+ and external_course.enrollment_end
and course_run.enrollment_end.date()
- != emeritus_course.enrollment_end.date()
+ != external_course.enrollment_end.date()
)
):
- course_run.start_date = emeritus_course.start_date
- course_run.end_date = emeritus_course.end_date
- course_run.enrollment_end = emeritus_course.enrollment_end
+ course_run.start_date = external_course.start_date
+ course_run.end_date = external_course.end_date
+ course_run.enrollment_end = external_course.enrollment_end
course_run.save()
is_updated = True
return course_run, is_created, is_updated
-def create_who_should_enroll_in_page(course_page, who_should_enroll_list):
+def create_who_should_enroll_in_page(course_page, who_should_enroll_list, keymap):
"""
- Creates `WhoShouldEnrollPage` for Emeritus course.
+ Creates `WhoShouldEnrollPage` for external course.
Args:
course_page(ExternalCoursePage): ExternalCoursePage object.
@@ -636,16 +710,16 @@ def create_who_should_enroll_in_page(course_page, who_should_enroll_list):
)
who_should_enroll_page = WhoShouldEnrollPage(
- heading=EmeritusKeyMap.WHO_SHOULD_ENROLL_PAGE_HEADING.value,
+ heading=keymap.who_should_enroll_page_heading,
content=content,
)
course_page.add_child(instance=who_should_enroll_page)
who_should_enroll_page.save()
-def create_learning_outcomes_page(course_page, outcomes_list):
+def create_learning_outcomes_page(course_page, outcomes_list, keymap):
"""
- Creates `LearningOutcomesPage` for Emeritus course.
+ Creates `LearningOutcomesPage` for external course.
Args:
course_page(ExternalCoursePage): ExternalCoursePage object.
@@ -656,21 +730,21 @@ def create_learning_outcomes_page(course_page, outcomes_list):
)
learning_outcome_page = LearningOutcomesPage(
- heading=EmeritusKeyMap.LEARNING_OUTCOMES_PAGE_HEADING.value,
- sub_heading=EmeritusKeyMap.LEARNING_OUTCOMES_PAGE_SUBHEAD.value,
+ heading=keymap.learning_outcomes_page_heading,
+ sub_heading=keymap.learning_outcomes_page_subhead,
outcome_items=outcome_items,
)
course_page.add_child(instance=learning_outcome_page)
learning_outcome_page.save()
-def create_or_update_certificate_page(course_page, emeritus_course):
+def create_or_update_certificate_page(course_page, external_course):
"""
Creates or Updates certificate page for a course page.
Args:
course_page(ExternalCoursePage): ExternalCoursePage object
- emeritus_course(EmeritusCourse): EmeritusCourse object
+ external_course(ExternalCourse): ExternalCourse object
Returns:
tuple: (CertificatePage, Is Page Created, Is Page Updated)
@@ -682,8 +756,8 @@ def create_or_update_certificate_page(course_page, emeritus_course):
if not certificate_page:
certificate_page = CertificatePage(
- product_name=f"Certificate for {emeritus_course.course_title}",
- CEUs=emeritus_course.CEUs,
+ product_name=f"Certificate for {external_course.course_title}",
+ CEUs=external_course.CEUs,
live=False,
)
course_page.add_child(instance=certificate_page)
@@ -692,8 +766,8 @@ def create_or_update_certificate_page(course_page, emeritus_course):
else:
latest_revision = certificate_page.get_latest_revision_as_object()
- if latest_revision.CEUs != emeritus_course.CEUs:
- latest_revision.CEUs = emeritus_course.CEUs
+ if latest_revision.CEUs != external_course.CEUs:
+ latest_revision.CEUs = external_course.CEUs
is_updated = True
if is_updated:
@@ -702,9 +776,9 @@ def create_or_update_certificate_page(course_page, emeritus_course):
return certificate_page, is_created, is_updated
-def parse_emeritus_data_str(items_str):
+def parse_external_course_data_str(items_str):
"""
- Parses `WhoShouldEnrollPage` and `LearningOutcomesPage` items for the Emeritus API.
+ Parses `WhoShouldEnrollPage` and `LearningOutcomesPage` items for the external API.
Args:
items_str(str): String containing a list of items separated by `\r\n`.
@@ -714,3 +788,18 @@ def parse_emeritus_data_str(items_str):
"""
items_list = items_str.strip().split("\r\n")
return [item.replace("●", "").strip() for item in items_list][1:]
+
+
+def create_course_overview_page(
+ course_page: ExternalCoursePage, external_course: ExternalCourse
+):
+ """
+ Creates `CourseOverviewPage` for External course.
+
+ Args:
+ course_page(ExternalCoursePage): ExternalCoursePage object.
+ external_course(ExternalCourse): ExternalCourse object
+ """
+ overview_page = CourseOverviewPage(overview=external_course.description)
+ course_page.add_child(instance=overview_page)
+ overview_page.save()
diff --git a/courses/sync_external_courses/emeritus_api_client.py b/courses/sync_external_courses/external_course_sync_api_client.py
similarity index 85%
rename from courses/sync_external_courses/emeritus_api_client.py
rename to courses/sync_external_courses/external_course_sync_api_client.py
index 448fb275d..82d19295c 100644
--- a/courses/sync_external_courses/emeritus_api_client.py
+++ b/courses/sync_external_courses/external_course_sync_api_client.py
@@ -1,5 +1,5 @@
"""
-API client for Emeritus
+External course sync API client
"""
import json
@@ -8,15 +8,15 @@
from django.conf import settings
-class EmeritusAPIClient:
+class ExternalCourseSyncAPIClient:
"""
- API client for Emeritus
+ External course sync API client
"""
def __init__(self):
- self.api_key = settings.EMERITUS_API_KEY
- self.base_url = settings.EMERITUS_API_BASE_URL
- self.request_timeout = settings.EMERITUS_API_REQUEST_TIMEOUT
+ self.api_key = settings.EXTERNAL_COURSE_SYNC_API_KEY
+ self.base_url = settings.EXTERNAL_COURSE_SYNC_API_BASE_URL
+ self.request_timeout = settings.EXTERNAL_COURSE_SYNC_API_REQUEST_TIMEOUT
def get_queries_list(self):
"""
diff --git a/courses/sync_external_courses/emeritus_api_client_test.py b/courses/sync_external_courses/external_course_sync_api_client_test.py
similarity index 54%
rename from courses/sync_external_courses/emeritus_api_client_test.py
rename to courses/sync_external_courses/external_course_sync_api_client_test.py
index 3cd6589d7..c369c6e53 100644
--- a/courses/sync_external_courses/emeritus_api_client_test.py
+++ b/courses/sync_external_courses/external_course_sync_api_client_test.py
@@ -1,5 +1,5 @@
"""
-Tests for emeritus_api_client
+Tests for external_course_sync_api_client
"""
import json
@@ -7,7 +7,9 @@
import pytest
-from courses.sync_external_courses.emeritus_api_client import EmeritusAPIClient
+from courses.sync_external_courses.external_course_sync_api_client import (
+ ExternalCourseSyncAPIClient,
+)
from mitxpro.test_utils import MockResponse
from mitxpro.utils import now_in_utc
@@ -22,7 +24,7 @@
),
[
(
- "courses.sync_external_courses.emeritus_api_client.requests.get",
+ "courses.sync_external_courses.external_course_sync_api_client.requests.get",
MockResponse(
{
"results": [
@@ -35,25 +37,25 @@
),
"get_queries_list",
[],
- "https://test-emeritus-api.io/api/queries?api_key=test_emeritus_api_key",
+ "https://test-external-course-sync-api.io/api/queries?api_key=test_external_course_sync_api_key",
),
(
- "courses.sync_external_courses.emeritus_api_client.requests.get",
+ "courses.sync_external_courses.external_course_sync_api_client.requests.get",
MockResponse({"job": {"status": 1}}),
"get_job_status",
[12],
- "https://test-emeritus-api.io/api/jobs/12?api_key=test_emeritus_api_key",
+ "https://test-external-course-sync-api.io/api/jobs/12?api_key=test_external_course_sync_api_key",
),
(
- "courses.sync_external_courses.emeritus_api_client.requests.get",
+ "courses.sync_external_courses.external_course_sync_api_client.requests.get",
MockResponse({"query_result": {"data": {}}}),
"get_query_result",
[20],
- "https://test-emeritus-api.io/api/query_results/20?api_key=test_emeritus_api_key",
+ "https://test-external-course-sync-api.io/api/query_results/20?api_key=test_external_course_sync_api_key",
),
],
)
-def test_emeritus_api_client_get_requests( # noqa: PLR0913
+def test_external_course_sync_api_client_get_requests( # noqa: PLR0913
mocker,
settings,
patch_request_path,
@@ -62,14 +64,16 @@ def test_emeritus_api_client_get_requests( # noqa: PLR0913
args,
expected_api_url,
):
- settings.EMERITUS_API_KEY = "test_emeritus_api_key"
- settings.EMERITUS_API_BASE_URL = "https://test-emeritus-api.io"
- settings.EMERITUS_API_REQUEST_TIMEOUT = 60
+ settings.EXTERNAL_COURSE_SYNC_API_KEY = "test_external_course_sync_api_key"
+ settings.EXTERNAL_COURSE_SYNC_API_BASE_URL = (
+ "https://test-external-course-sync-api.io"
+ )
+ settings.EXTERNAL_COURSE_SYNC_API_REQUEST_TIMEOUT = 60
mock_get = mocker.patch(patch_request_path)
mock_get.return_value = mock_response
- client = EmeritusAPIClient()
+ client = ExternalCourseSyncAPIClient()
client_method_map = {
"get_queries_list": client.get_queries_list,
"get_job_status": client.get_job_status,
@@ -84,23 +88,25 @@ def test_emeritus_api_client_get_requests( # noqa: PLR0913
def test_get_query_response(mocker, settings):
"""
- Tests that `EmeritusAPIClient.get_query_response` makes the expected post request.
+ Tests that `ExternalCourseSyncAPIClient.get_query_response` makes the expected post request.
"""
end_date = now_in_utc()
start_date = end_date - timedelta(days=1)
- settings.EMERITUS_API_KEY = "test_emeritus_api_key"
- settings.EMERITUS_API_BASE_URL = "https://test-emeritus-api.io"
+ settings.EXTERNAL_COURSE_SYNC_API_KEY = "test_external_course_sync_api_key"
+ settings.EXTERNAL_COURSE_SYNC_API_BASE_URL = (
+ "https://test-external-course-sync-api.io"
+ )
mock_post = mocker.patch(
- "courses.sync_external_courses.emeritus_api_client.requests.post"
+ "courses.sync_external_courses.external_course_sync_api_client.requests.post"
)
mock_post.return_value = MockResponse({"job": {"id": 1}})
- client = EmeritusAPIClient()
+ client = ExternalCourseSyncAPIClient()
client.get_query_response(1, start_date, end_date)
mock_post.assert_called_once_with(
- "https://test-emeritus-api.io/api/queries/1/results?api_key=test_emeritus_api_key",
+ "https://test-external-course-sync-api.io/api/queries/1/results?api_key=test_external_course_sync_api_key",
data=json.dumps(
{
"parameters": {
diff --git a/courses/sync_external_courses/emeritus_api_test.py b/courses/sync_external_courses/external_course_sync_api_test.py
similarity index 59%
rename from courses/sync_external_courses/emeritus_api_test.py
rename to courses/sync_external_courses/external_course_sync_api_test.py
index e42e3c286..f3528b1e3 100644
--- a/courses/sync_external_courses/emeritus_api_test.py
+++ b/courses/sync_external_courses/external_course_sync_api_test.py
@@ -20,21 +20,24 @@
from cms.models import CertificatePage
from courses.factories import CourseFactory, CourseRunFactory, PlatformFactory
from courses.models import Course
-from courses.sync_external_courses.emeritus_api import (
- EmeritusCourse,
+from courses.sync_external_courses.external_course_sync_api import (
+ EMERITUS_PLATFORM_NAME,
+ GLOBAL_ALUMNI_PLATFORM_NAME,
EmeritusKeyMap,
+ ExternalCourse,
+ GlobalAlumniKeyMap,
create_learning_outcomes_page,
create_or_update_certificate_page,
- create_or_update_emeritus_course_page,
- create_or_update_emeritus_course_run,
+ create_or_update_external_course_page,
+ create_or_update_external_course_run,
create_or_update_product_and_product_version,
create_who_should_enroll_in_page,
- fetch_emeritus_courses,
- generate_emeritus_course_run_tag,
+ fetch_external_courses,
generate_external_course_run_courseware_id,
- parse_emeritus_data_str,
+ generate_external_course_run_tag,
+ parse_external_course_data_str,
save_page_revision,
- update_emeritus_course_runs,
+ update_external_course_runs,
)
from ecommerce.factories import ProductFactory, ProductVersionFactory
from mitxpro.test_utils import MockResponse
@@ -42,85 +45,111 @@
@pytest.fixture
-def emeritus_course_data():
+def external_course_data(request):
"""
- Emeritus Course data with Future dates.
+ External Course data with Future dates.
"""
with Path(
"courses/sync_external_courses/test_data/batch_test.json"
).open() as test_data_file:
- emeritus_course_data = json.load(test_data_file)["rows"][0]
+ external_course_data = json.load(test_data_file)["rows"][0]
- emeritus_course_data["start_date"] = "2099-09-30"
- emeritus_course_data["end_date"] = "2099-11-30"
- emeritus_course_data["course_run_code"] = "MO-DBIP.ELE-99-09#1"
- return emeritus_course_data
+ params = request.param
+ platform = params.get("platform", EMERITUS_PLATFORM_NAME)
+ if platform == EMERITUS_PLATFORM_NAME:
+ external_course_data["course_run_code"] = "MO-DBIP.ELE-99-09#1"
+ elif platform == GLOBAL_ALUMNI_PLATFORM_NAME:
+ external_course_data["course_run_code"] = "MXP-DBIP.ELE-99-09#1"
+ external_course_data.pop("ceu", None)
+
+ external_course_data["start_date"] = "2099-09-30"
+ external_course_data["end_date"] = "2099-11-30"
+ return external_course_data
@pytest.fixture
-def emeritus_expired_course_data(emeritus_course_data):
+def external_expired_course_data(external_course_data):
"""
- Emeritus course JSON with expired dates.
+ External course JSON with expired dates.
"""
- expired_emeritus_course_json = emeritus_course_data.copy()
- expired_emeritus_course_json["start_date"] = (
+ expired_external_course_json = external_course_data.copy()
+ expired_external_course_json["start_date"] = (
datetime.now() - timedelta(days=2) # noqa: DTZ005
).strftime("%Y-%m-%d")
- expired_emeritus_course_json["end_date"] = (
+ expired_external_course_json["end_date"] = (
datetime.now() - timedelta(days=1) # noqa: DTZ005
).strftime("%Y-%m-%d")
- return expired_emeritus_course_json
+ return expired_external_course_json
@pytest.fixture
-def emeritus_course_with_bad_data(emeritus_course_data):
+def external_course_with_bad_data(external_course_data):
"""
- Emeritus course JSON with bad data, i.e. program_name, course_code, course_run_code is null.
+ External course JSON with bad data, i.e. program_name, course_code, course_run_code is null.
"""
- bad_data_emeritus_course_json = emeritus_course_data.copy()
- bad_data_emeritus_course_json["program_name"] = None
- return bad_data_emeritus_course_json
+ bad_data_external_course_json = external_course_data.copy()
+ bad_data_external_course_json["program_name"] = None
+ return bad_data_external_course_json
@pytest.fixture
-def emeritus_course_data_with_null_price(emeritus_course_data):
+def external_course_data_with_null_price(external_course_data):
"""
- Emeritus course JSON with null price.
+ External course JSON with null price.
"""
- emeritus_course_json = emeritus_course_data.copy()
- emeritus_course_json["list_price"] = None
- return emeritus_course_json
+ external_course_json = external_course_data.copy()
+ external_course_json["list_price"] = None
+ return external_course_json
@pytest.fixture
-def emeritus_course_data_with_non_usd_price(emeritus_course_data):
+def external_course_data_with_non_usd_price(external_course_data):
"""
- Emeritus course JSON with non USD price.
+ External course JSON with non USD price.
"""
- emeritus_course_json = emeritus_course_data.copy()
- emeritus_course_json["list_currency"] = "INR"
- emeritus_course_json["course_run_code"] = "MO-INRC-98-10#1"
- return emeritus_course_json
+ external_course_json = external_course_data.copy()
+ external_course_json["list_currency"] = "INR"
+ external_course_json["course_run_code"] = (
+ f"{external_course_data['course_run_code'].split('-')[0]}-INRC-98-10#1"
+ )
+ return external_course_json
+
+
+def get_keymap(run_code):
+ return EmeritusKeyMap() if run_code.startswith("MO") else GlobalAlumniKeyMap()
+
+
+def get_platform(run_code):
+ return (
+ EMERITUS_PLATFORM_NAME
+ if run_code.startswith("MO")
+ else GLOBAL_ALUMNI_PLATFORM_NAME
+ )
@pytest.mark.parametrize(
- ("emeritus_course_run_code", "expected_course_run_tag"),
+ ("external_course_run_code", "expected_course_run_tag"),
[
("MO-EOB-18-01#1", "18-01-1"),
+ ("MXP-EOB-18-01#1", "18-01-1"),
("MO-EOB-08-01#1", "08-01-1"),
+ ("MXP-EOB-08-01#1", "08-01-1"),
("MO-EOB-08-12#1", "08-12-1"),
+ ("MXP-EOB-08-12#1", "08-12-1"),
("MO-EOB-18-01#12", "18-01-12"),
+ ("MXP-EOB-18-01#12", "18-01-12"),
("MO-EOB-18-01#212", "18-01-212"),
+ ("MXP-EOB-18-01#212", "18-01-212"),
],
)
-def test_generate_emeritus_course_run_tag(
- emeritus_course_run_code, expected_course_run_tag
+def test_generate_external_course_run_tag(
+ external_course_run_code, expected_course_run_tag
):
"""
- Tests that `generate_emeritus_course_run_tag` generates the expected course tag for Emeritus Course Run Codes.
+ Tests that `generate_external_course_run_tag` generates the expected course tag for External Course Run Codes.
"""
assert (
- generate_emeritus_course_run_tag(emeritus_course_run_code)
+ generate_external_course_run_tag(external_course_run_code)
== expected_course_run_tag
)
@@ -147,6 +176,11 @@ def test_generate_external_course_run_courseware_id(
)
+@pytest.mark.parametrize(
+ "external_course_data",
+ [{"platform": EMERITUS_PLATFORM_NAME}, {"platform": GLOBAL_ALUMNI_PLATFORM_NAME}],
+ indirect=True,
+)
@pytest.mark.parametrize(
(
"create_course_page",
@@ -163,33 +197,33 @@ def test_generate_external_course_run_courseware_id(
],
)
@pytest.mark.django_db
-def test_create_or_update_emeritus_course_page( # noqa: PLR0913
+def test_create_or_update_external_course_page( # noqa: PLR0913
create_course_page,
publish_page,
is_live_and_draft,
create_image,
test_image_name_without_extension,
- emeritus_course_data,
+ external_course_data,
):
"""
- Test that `create_or_update_emeritus_course_page` creates a new course or updates the existing.
+ Test that `create_or_update_external_course_page` creates a new course or updates the existing.
"""
home_page = HomePageFactory.create(title="Home Page", subhead="subhead
")
course_index_page = CourseIndexPageFactory.create(parent=home_page, title="Courses")
course = CourseFactory.create(is_external=True)
if test_image_name_without_extension:
- emeritus_course_data["image_name"] = emeritus_course_data["image_name"].split(
+ external_course_data["image_name"] = external_course_data["image_name"].split(
"."
)[0]
if create_image:
- ImageFactory.create(title=emeritus_course_data["image_name"])
+ ImageFactory.create(title=external_course_data["image_name"])
if create_course_page:
external_course_page = ExternalCoursePageFactory.create(
course=course,
- title=emeritus_course_data["program_name"],
+ title=external_course_data["program_name"],
external_marketing_url="",
duration="",
description="",
@@ -204,21 +238,27 @@ def test_create_or_update_emeritus_course_page( # noqa: PLR0913
else:
external_course_page.unpublish()
+ keymap = get_keymap(external_course_data["course_run_code"])
external_course_page, course_page_created, course_page_updated = (
- create_or_update_emeritus_course_page(
- course_index_page, course, EmeritusCourse(emeritus_course_data)
+ create_or_update_external_course_page(
+ course_index_page,
+ course,
+ ExternalCourse(external_course_data, keymap=keymap),
+ keymap=keymap,
)
)
external_course_page = external_course_page.revisions.last().as_object()
assert external_course_page.external_marketing_url == clean_url(
- emeritus_course_data["landing_page_url"], remove_query_params=True
+ external_course_data["landing_page_url"], remove_query_params=True
)
assert external_course_page.course == course
assert (
- external_course_page.duration == f"{emeritus_course_data['total_weeks']} Weeks"
+ external_course_page.duration == f"{external_course_data['total_weeks']} Weeks"
)
- assert external_course_page.description == emeritus_course_data["description"]
+ assert external_course_page.min_weeks == external_course_data["total_weeks"]
+ assert external_course_page.max_weeks == external_course_data["total_weeks"]
+ assert external_course_page.description == external_course_data["description"]
assert course_page_created == (not create_course_page)
assert course_page_updated == create_course_page
@@ -231,22 +271,25 @@ def test_create_or_update_emeritus_course_page( # noqa: PLR0913
assert external_course_page.live
assert (
external_course_page.title
- == emeritus_course_data["program_name"] + " Draft"
+ == external_course_data["program_name"] + " Draft"
)
else:
- assert external_course_page.title == emeritus_course_data["program_name"]
+ assert external_course_page.title == external_course_data["program_name"]
if create_image:
assert (
external_course_page.background_image.title
- == emeritus_course_data["image_name"]
+ == external_course_data["image_name"]
)
assert (
external_course_page.thumbnail_image.title
- == emeritus_course_data["image_name"]
+ == external_course_data["image_name"]
)
+@pytest.mark.parametrize(
+ "external_course_data", [{"platform": EMERITUS_PLATFORM_NAME}], indirect=True
+)
@pytest.mark.parametrize(
("existing_cert_page", "publish_certificate", "is_live_and_draft"),
[
@@ -258,7 +301,7 @@ def test_create_or_update_emeritus_course_page( # noqa: PLR0913
)
@pytest.mark.django_db
def test_create_or_update_certificate_page(
- emeritus_course_data, existing_cert_page, publish_certificate, is_live_and_draft
+ external_course_data, existing_cert_page, publish_certificate, is_live_and_draft
):
"""
Tests that `create_or_update_certificate_page` updates the CEUs and does not change the draft or live state.
@@ -269,7 +312,7 @@ def test_create_or_update_certificate_page(
external_course_page = ExternalCoursePageFactory.create(
parent=course_index_page,
course=course,
- title=emeritus_course_data["program_name"],
+ title=external_course_data["program_name"],
external_marketing_url="",
duration="",
description="",
@@ -286,11 +329,13 @@ def test_create_or_update_certificate_page(
else:
certificate_page.unpublish()
+ keymap = get_keymap(external_course_data["course_run_code"])
certificate_page, is_created, is_updated = create_or_update_certificate_page(
- external_course_page, EmeritusCourse(emeritus_course_data)
+ external_course_page,
+ ExternalCourse(external_course_data, keymap=keymap),
)
certificate_page = certificate_page.revisions.last().as_object()
- assert certificate_page.CEUs == emeritus_course_data["ceu"]
+ assert certificate_page.CEUs == external_course_data["ceu"]
assert is_created == (not existing_cert_page)
assert is_updated == existing_cert_page
@@ -301,8 +346,11 @@ def test_create_or_update_certificate_page(
assert certificate_page.live
+@pytest.mark.parametrize(
+ "external_course_vendor_keymap", [EmeritusKeyMap, GlobalAlumniKeyMap]
+)
@pytest.mark.django_db
-def test_create_who_should_enroll_in_page():
+def test_create_who_should_enroll_in_page(external_course_vendor_keymap):
"""
Tests that `create_who_should_enroll_in_page` creates the `WhoShouldEnrollPage`.
"""
@@ -317,16 +365,21 @@ def test_create_who_should_enroll_in_page():
"looking to add critical cybersecurity knowledge and foundational lessons to their resume"
)
create_who_should_enroll_in_page(
- course_page, parse_emeritus_data_str(who_should_enroll_str)
+ course_page,
+ parse_external_course_data_str(who_should_enroll_str),
+ keymap=external_course_vendor_keymap(),
)
- assert parse_emeritus_data_str(who_should_enroll_str) == [
+ assert parse_external_course_data_str(who_should_enroll_str) == [
item.value.source for item in course_page.who_should_enroll.content
]
assert course_page.who_should_enroll is not None
+@pytest.mark.parametrize(
+ "external_course_vendor_keymap", [EmeritusKeyMap, GlobalAlumniKeyMap]
+)
@pytest.mark.django_db
-def test_create_learning_outcomes_page():
+def test_create_learning_outcomes_page(external_course_vendor_keymap):
"""
Tests that `create_learning_outcomes_page` creates the `LearningOutcomesPage`.
"""
@@ -340,17 +393,19 @@ def test_create_learning_outcomes_page():
"organizations to prepare themselves against cybersecurity attacks"
)
create_learning_outcomes_page(
- course_page, parse_emeritus_data_str(learning_outcomes_str)
+ course_page,
+ parse_external_course_data_str(learning_outcomes_str),
+ keymap=external_course_vendor_keymap(),
)
- assert parse_emeritus_data_str(learning_outcomes_str) == [
+ assert parse_external_course_data_str(learning_outcomes_str) == [
item.value for item in course_page.outcomes.outcome_items
]
assert course_page.outcomes is not None
-def test_parse_emeritus_data_str():
+def test_parse_external_course_data_str():
"""
- Tests that `parse_emeritus_data_str` parses who should enroll and learning outcomes strings as expected.
+ Tests that `parse_external_course_data_str` parses who should enroll and learning outcomes strings as expected.
"""
data_str = (
"This program will enable you to:\r\n● Gain an overview of cybersecurity risk "
@@ -360,7 +415,7 @@ def test_parse_emeritus_data_str():
"of specific threat models and methodologies\r\n● Understand the guidelines for "
"organizations to prepare themselves against cybersecurity attacks"
)
- assert parse_emeritus_data_str(data_str) == [
+ assert parse_external_course_data_str(data_str) == [
"Gain an overview of cybersecurity risk management, including "
"its foundational concepts and relevant regulations",
"Explore the domains covering various aspects of cloud technology",
@@ -370,6 +425,11 @@ def test_parse_emeritus_data_str():
]
+@pytest.mark.parametrize(
+ "external_course_data",
+ [{"platform": EMERITUS_PLATFORM_NAME}, {"platform": GLOBAL_ALUMNI_PLATFORM_NAME}],
+ indirect=True,
+)
@pytest.mark.parametrize(
("create_existing_course_run", "empty_dates"),
[
@@ -379,18 +439,19 @@ def test_parse_emeritus_data_str():
],
)
@pytest.mark.django_db
-def test_create_or_update_emeritus_course_run(
- create_existing_course_run, empty_dates, emeritus_course_data
+def test_create_or_update_external_course_run(
+ create_existing_course_run, empty_dates, external_course_data
):
"""
- Tests that `create_or_update_emeritus_course_run` creates or updates a course run
+ Tests that `create_or_update_external_course_run` creates or updates a course run
"""
- emeritus_course = EmeritusCourse(emeritus_course_data)
+ keymap = get_keymap(external_course_data["course_run_code"])
+ external_course = ExternalCourse(external_course_data, keymap=keymap)
course = CourseFactory.create()
if create_existing_course_run:
run = CourseRunFactory.create(
course=course,
- external_course_run_id=emeritus_course.course_run_code,
+ external_course_run_id=external_course.course_run_code,
enrollment_start=None,
enrollment_end=None,
expiration_date=None,
@@ -400,12 +461,12 @@ def test_create_or_update_emeritus_course_run(
run.end_date = None
run.save()
- run, run_created, run_updated = create_or_update_emeritus_course_run(
- course, emeritus_course
+ run, run_created, run_updated = create_or_update_external_course_run(
+ course, external_course
)
course_runs = course.courseruns.all()
course_run_courseware_id = generate_external_course_run_courseware_id(
- emeritus_course.course_run_tag, course.readable_id
+ external_course.course_run_tag, course.readable_id
)
assert len(course_runs) == 1
@@ -414,47 +475,54 @@ def test_create_or_update_emeritus_course_run(
assert run_updated == create_existing_course_run
if create_existing_course_run:
expected_data = {
- "external_course_run_id": emeritus_course.course_run_code,
- "start_date": emeritus_course.start_date,
- "end_date": emeritus_course.end_date,
- "enrollment_end": emeritus_course.enrollment_end,
+ "external_course_run_id": external_course.course_run_code,
+ "start_date": external_course.start_date,
+ "end_date": external_course.end_date,
+ "enrollment_end": external_course.enrollment_end,
}
else:
expected_data = {
- "title": emeritus_course.course_title,
- "external_course_run_id": emeritus_course.course_run_code,
+ "title": external_course.course_title,
+ "external_course_run_id": external_course.course_run_code,
"courseware_id": course_run_courseware_id,
- "run_tag": emeritus_course.course_run_tag,
- "start_date": emeritus_course.start_date,
- "end_date": emeritus_course.end_date,
- "enrollment_end": emeritus_course.enrollment_end,
+ "run_tag": external_course.course_run_tag,
+ "start_date": external_course.start_date,
+ "end_date": external_course.end_date,
+ "enrollment_end": external_course.enrollment_end,
"live": True,
}
for attr_name, expected_value in expected_data.items():
assert getattr(course_runs[0], attr_name) == expected_value
+@pytest.mark.parametrize(
+ "external_course_data",
+ [{"platform": EMERITUS_PLATFORM_NAME}, {"platform": GLOBAL_ALUMNI_PLATFORM_NAME}],
+ indirect=True,
+)
@pytest.mark.parametrize("create_existing_data", [True, False])
@pytest.mark.django_db
-def test_update_emeritus_course_runs( # noqa: PLR0915
+def test_update_external_course_runs( # noqa: PLR0915, PLR0913
+ external_course_data,
create_existing_data,
- emeritus_expired_course_data,
- emeritus_course_with_bad_data,
- emeritus_course_data_with_null_price,
- emeritus_course_data_with_non_usd_price,
+ external_expired_course_data,
+ external_course_with_bad_data,
+ external_course_data_with_null_price,
+ external_course_data_with_non_usd_price,
):
"""
- Tests that `update_emeritus_course_runs` creates new courses and updates existing.
+ Tests that `update_external_course_runs` creates new courses and updates existing.
"""
with Path(
"courses/sync_external_courses/test_data/batch_test.json"
).open() as test_data_file:
- emeritus_course_runs = json.load(test_data_file)["rows"]
+ external_course_runs = json.load(test_data_file)["rows"]
- platform = PlatformFactory.create(name=EmeritusKeyMap.PLATFORM_NAME.value)
+ platform_name = get_platform(external_course_data["course_run_code"])
+ platform = PlatformFactory.create(name=platform_name)
if create_existing_data:
- for run in random.sample(emeritus_course_runs, len(emeritus_course_runs) // 2):
+ for run in random.sample(external_course_runs, len(external_course_runs) // 2):
course = CourseFactory.create(
title=run["program_name"],
platform=platform,
@@ -486,11 +554,12 @@ def test_update_emeritus_course_runs( # noqa: PLR0915
product = ProductFactory.create(content_object=course_run)
ProductVersionFactory.create(product=product, price=run["list_price"])
- emeritus_course_runs.append(emeritus_expired_course_data)
- emeritus_course_runs.append(emeritus_course_with_bad_data)
- emeritus_course_runs.append(emeritus_course_data_with_null_price)
- emeritus_course_runs.append(emeritus_course_data_with_non_usd_price)
- stats = update_emeritus_course_runs(emeritus_course_runs)
+ external_course_runs.append(external_expired_course_data)
+ external_course_runs.append(external_course_with_bad_data)
+ external_course_runs.append(external_course_data_with_null_price)
+ external_course_runs.append(external_course_data_with_non_usd_price)
+ keymap = get_keymap(external_course_data["course_run_code"])
+ stats = update_external_course_runs(external_course_runs, keymap=keymap)
courses = Course.objects.filter(platform=platform)
num_courses_created = 2 if create_existing_data else 4
@@ -514,51 +583,56 @@ def test_update_emeritus_course_runs( # noqa: PLR0915
assert len(stats["product_versions_created"]) == num_product_versions_created
assert len(stats["course_runs_without_prices"]) == 1
- for emeritus_course_run in emeritus_course_runs:
+ for external_course_run in external_course_runs:
if (
- emeritus_course_run["course_run_code"] in stats["course_runs_skipped"]
- or emeritus_course_run["course_run_code"] in stats["course_runs_expired"]
+ external_course_run["course_run_code"] in stats["course_runs_skipped"]
+ or external_course_run["course_run_code"] in stats["course_runs_expired"]
):
continue
course = Course.objects.filter(
platform=platform,
- external_course_id=emeritus_course_run["course_code"],
+ external_course_id=external_course_run["course_code"],
is_external=True,
).first()
assert course is not None
assert (
course.courseruns.filter(
- external_course_run_id=emeritus_course_run["course_run_code"]
+ external_course_run_id=external_course_run["course_run_code"]
).count()
== 1
)
assert hasattr(course, "externalcoursepage")
assert (
course.courseruns.filter(
- external_course_run_id=emeritus_course_run["course_run_code"]
+ external_course_run_id=external_course_run["course_run_code"]
)
.first()
.current_price
- == emeritus_course_run["list_price"]
+ == external_course_run["list_price"]
)
course_page = course.externalcoursepage
- if emeritus_course_run["program_for"]:
+ if external_course_run["program_for"]:
assert course_page.who_should_enroll is not None
- if emeritus_course_run["learning_outcomes"]:
+ if external_course_run["learning_outcomes"]:
assert course_page.outcomes is not None
- if emeritus_course_run.get("ceu", ""):
+ if external_course_run.get("ceu", ""):
certificate_page = course_page.get_child_page_of_type_including_draft(
CertificatePage
)
assert certificate_page
- assert certificate_page.CEUs == emeritus_course_run["ceu"]
+ assert certificate_page.CEUs == external_course_run["ceu"]
-def test_fetch_emeritus_courses_success(settings, mocker):
+@pytest.mark.parametrize(
+ "external_course_vendor_keymap", [EmeritusKeyMap, GlobalAlumniKeyMap]
+)
+def test_fetch_external_courses_success(
+ settings, mocker, external_course_vendor_keymap
+):
"""
- Tests that `fetch_emeritus_courses` makes the required calls to the `Emeritus` API. Tests the success scenario.
+ Tests that `fetch_external_courses` makes the required calls to the `Emeritus` API. Tests the success scenario.
Here is the expected flow:
1. Make a get request to get a list of reports.
@@ -568,69 +642,80 @@ def test_fetch_emeritus_courses_success(settings, mocker):
5. If job status is 1 or 2, it is in progress. Wait for 2 seconds and make a get request for Job status.
6. If job status is 3, the results are ready, make a get request to collect the results and return the data.
"""
- settings.EMERITUS_API_BASE_URL = "https://test_emeritus_api.io"
- settings.EMERITUS_API_KEY = "test_emeritus_api_key"
- settings.EMERITUS_API_REQUEST_TIMEOUT = 60
+ settings.EXTERNAL_COURSE_SYNC_API_BASE_URL = (
+ "https://test_external_course_sync_api.io"
+ )
+ settings.EXTERNAL_COURSE_SYNC_API_KEY = "test_EXTERNAL_COURSE_SYNC_API_KEY"
+ settings.EXTERNAL_COURSE_SYNC_API_REQUEST_TIMEOUT = 60
mock_get = mocker.patch(
- "courses.sync_external_courses.emeritus_api_client.requests.get"
+ "courses.sync_external_courses.external_course_sync_api_client.requests.get"
)
mock_post = mocker.patch(
- "courses.sync_external_courses.emeritus_api_client.requests.post"
+ "courses.sync_external_courses.external_course_sync_api_client.requests.post"
)
with Path(
"courses/sync_external_courses/test_data/batch_test.json"
).open() as test_data_file:
- emeritus_course_runs = json.load(test_data_file)
+ external_course_runs = json.load(test_data_file)
+ keymap = external_course_vendor_keymap()
batch_query = {
"id": 77,
- "name": "Batch",
+ "name": keymap.report_names[0],
}
mock_get.side_effect = [
MockResponse({"results": [batch_query]}),
MockResponse({"job": {"status": 1}}),
MockResponse({"job": {"status": 2}}),
MockResponse({"job": {"status": 3, "query_result_id": 1}}),
- MockResponse({"query_result": {"data": emeritus_course_runs}}),
+ MockResponse({"query_result": {"data": external_course_runs}}),
]
mock_post.side_effect = [MockResponse({"job": {"id": 1}})]
- actual_course_runs = fetch_emeritus_courses()
+ actual_course_runs = fetch_external_courses(keymap=keymap)
mock_get.assert_any_call(
- "https://test_emeritus_api.io/api/queries?api_key=test_emeritus_api_key",
+ "https://test_external_course_sync_api.io/api/queries?api_key=test_EXTERNAL_COURSE_SYNC_API_KEY",
timeout=60,
)
mock_post.assert_called_once()
mock_get.assert_any_call(
- "https://test_emeritus_api.io/api/jobs/1?api_key=test_emeritus_api_key",
+ "https://test_external_course_sync_api.io/api/jobs/1?api_key=test_EXTERNAL_COURSE_SYNC_API_KEY",
timeout=60,
)
mock_get.assert_any_call(
- "https://test_emeritus_api.io/api/query_results/1?api_key=test_emeritus_api_key",
+ "https://test_external_course_sync_api.io/api/query_results/1?api_key=test_EXTERNAL_COURSE_SYNC_API_KEY",
timeout=60,
)
- assert actual_course_runs == emeritus_course_runs["rows"]
+ assert actual_course_runs == external_course_runs["rows"]
-def test_fetch_emeritus_courses_error(settings, mocker, caplog):
+@pytest.mark.parametrize(
+ "external_course_vendor_keymap", [EmeritusKeyMap, GlobalAlumniKeyMap]
+)
+def test_fetch_external_courses_error(
+ settings, mocker, caplog, external_course_vendor_keymap
+):
"""
- Tests that `fetch_emeritus_courses` specific calls to the Emeritus API and Fails for Job status 3 and 4.
+ Tests that `fetch_external_courses` specific calls to the External Course Sync API and Fails for Job status 3 and 4.
"""
- settings.EMERITUS_API_BASE_URL = "https://test_emeritus_api.com"
- settings.EMERITUS_API_KEY = "test_emeritus_api_key"
+ settings.EXTERNAL_COURSE_SYNC_API_BASE_URL = (
+ "https://test_external_course_sync_api.com"
+ )
+ settings.EXTERNAL_COURSE_SYNC_API_KEY = "test_EXTERNAL_COURSE_SYNC_API_KEY"
mock_get = mocker.patch(
- "courses.sync_external_courses.emeritus_api_client.requests.get"
+ "courses.sync_external_courses.external_course_sync_api_client.requests.get"
)
mock_post = mocker.patch(
- "courses.sync_external_courses.emeritus_api_client.requests.post"
+ "courses.sync_external_courses.external_course_sync_api_client.requests.post"
)
+ keymap = external_course_vendor_keymap()
batch_query = {
"id": 77,
- "name": "Batch",
+ "name": keymap.report_names[0],
}
mock_get.side_effect = [
MockResponse({"results": [batch_query]}),
@@ -640,11 +725,16 @@ def test_fetch_emeritus_courses_error(settings, mocker, caplog):
]
mock_post.side_effect = [MockResponse({"job": {"id": 1}})]
with caplog.at_level(logging.ERROR):
- fetch_emeritus_courses()
+ fetch_external_courses(keymap=keymap)
assert "Job failed!" in caplog.text
assert "Something unexpected happened!" in caplog.text
+@pytest.mark.parametrize(
+ "external_course_data",
+ [{"platform": EMERITUS_PLATFORM_NAME}, {"platform": GLOBAL_ALUMNI_PLATFORM_NAME}],
+ indirect=True,
+)
@pytest.mark.parametrize(
(
"create_existing_product",
@@ -663,7 +753,7 @@ def test_fetch_emeritus_courses_error(settings, mocker, caplog):
)
@pytest.mark.django_db
def test_create_or_update_product_and_product_version( # noqa: PLR0913
- emeritus_course_data,
+ external_course_data,
create_existing_product,
existing_price,
new_price,
@@ -674,18 +764,21 @@ def test_create_or_update_product_and_product_version( # noqa: PLR0913
"""
Tests that `create_or_update_product_and_product_version` creates or updates products and versions as required.
"""
- emeritus_course_data["list_price"] = new_price
- emeritus_course = EmeritusCourse(emeritus_course_data)
- platform = PlatformFactory.create(name=EmeritusKeyMap.PLATFORM_NAME)
+ external_course_data["list_price"] = new_price
+
+ keymap = get_keymap(external_course_data["course_run_code"])
+ platform_name = get_platform(external_course_data["course_run_code"])
+ external_course = ExternalCourse(external_course_data, keymap=keymap)
+ platform = PlatformFactory.create(name=platform_name)
course = CourseFactory.create(
- external_course_id=emeritus_course.course_code,
+ external_course_id=external_course.course_code,
platform=platform,
is_external=True,
- title=emeritus_course.course_title,
- readable_id=emeritus_course.course_readable_id,
+ title=external_course.course_title,
+ readable_id=external_course.course_readable_id,
live=True,
)
- course_run, _, _ = create_or_update_emeritus_course_run(course, emeritus_course)
+ course_run, _, _ = create_or_update_external_course_run(course, external_course)
if create_existing_product:
product = ProductFactory.create(content_object=course_run)
@@ -694,7 +787,7 @@ def test_create_or_update_product_and_product_version( # noqa: PLR0913
ProductVersionFactory.create(product=product, price=existing_price)
product_created, version_created = create_or_update_product_and_product_version(
- emeritus_course, course_run
+ external_course, course_run
)
assert course_run.current_price == expected_price
assert product_created == expected_product_created
@@ -729,9 +822,7 @@ def test_save_page_revision(is_draft_page, has_unpublished_changes):
external_course_page.save_revision()
latest_revision = external_course_page.get_latest_revision_as_object()
- latest_revision.external_marketing_url = (
- "https://test-emeritus-api.io/Internet-of-things-iot-design-and-applications"
- )
+ latest_revision.external_marketing_url = "https://test-external-course-sync-api.io/Internet-of-things-iot-design-and-applications"
save_page_revision(external_course_page, latest_revision)
assert external_course_page.live == (not is_draft_page)
@@ -740,6 +831,11 @@ def test_save_page_revision(is_draft_page, has_unpublished_changes):
assert external_course_page.has_unpublished_changes
+@pytest.mark.parametrize(
+ "external_course_data",
+ [{"platform": EMERITUS_PLATFORM_NAME}, {"platform": GLOBAL_ALUMNI_PLATFORM_NAME}],
+ indirect=True,
+)
@pytest.mark.parametrize(
("title", "course_code", "course_run_code", "is_valid"),
[
@@ -749,39 +845,67 @@ def test_save_page_revision(is_draft_page, has_unpublished_changes):
"MO-DBIP.ELE-99-07#1",
True,
),
+ (
+ "Internet of Things (IoT): Design and Applications ",
+ "MXP-DBIP",
+ "MXP-DBIP.ELE-99-07#1",
+ True,
+ ),
("", "MO-DBIP", "MO-DBIP.ELE-99-07#1", False),
+ ("", "MXP-DBIP", "MXP-DBIP.ELE-99-07#1", False),
(None, "MO-DBIP", "MO-DBIP.ELE-99-07#1", False),
+ (None, "MXP-DBIP", "MXP-DBIP.ELE-99-07#1", False),
(
" Internet of Things (IoT): Design and Applications ",
"",
"MO-DBIP.ELE-99-07#1",
False,
),
+ (
+ " Internet of Things (IoT): Design and Applications ",
+ "",
+ "MXP-DBIP.ELE-99-07#1",
+ False,
+ ),
(
" Internet of Things (IoT): Design and Applications",
None,
"MO-DBIP.ELE-99-07#1",
False,
),
+ (
+ " Internet of Things (IoT): Design and Applications",
+ None,
+ "MXP-DBIP.ELE-99-07#1",
+ False,
+ ),
("Internet of Things (IoT): Design and Applications", "MO-DBIP", "", False),
+ ("Internet of Things (IoT): Design and Applications", "MXP-DBIP", "", False),
("Internet of Things (IoT): Design and Applications", "MO-DBIP", None, False),
+ ("Internet of Things (IoT): Design and Applications", "MXP-DBIP", None, False),
("", "", "", False),
(None, None, None, False),
],
)
-def test_emeritus_course_validate_required_fields(
- emeritus_course_data, title, course_code, course_run_code, is_valid
+def test_external_course_validate_required_fields(
+ external_course_data, title, course_code, course_run_code, is_valid
):
"""
- Tests that EmeritusCourse.validate_required_fields validates required fields.
+ Tests that ExternalCourse.validate_required_fields validates required fields.
"""
- emeritus_course = EmeritusCourse(emeritus_course_data)
- emeritus_course.course_title = title.strip() if title else title
- emeritus_course.course_code = course_code
- emeritus_course.course_run_code = course_run_code
- assert emeritus_course.validate_required_fields() == is_valid
+ keymap = get_keymap(external_course_data["course_run_code"])
+ external_course = ExternalCourse(external_course_data, keymap=keymap)
+ external_course.course_title = title.strip() if title else title
+ external_course.course_code = course_code
+ external_course.course_run_code = course_run_code
+ assert external_course.validate_required_fields(keymap=keymap) == is_valid
+@pytest.mark.parametrize(
+ "external_course_data",
+ [{"platform": EMERITUS_PLATFORM_NAME}, {"platform": GLOBAL_ALUMNI_PLATFORM_NAME}],
+ indirect=True,
+)
@pytest.mark.parametrize(
("list_currency", "is_valid"),
[
@@ -792,17 +916,23 @@ def test_emeritus_course_validate_required_fields(
("PKR", False),
],
)
-def test_emeritus_course_validate_list_currency(
- emeritus_course_data, list_currency, is_valid
+def test_external_course_validate_list_currency(
+ external_course_data, list_currency, is_valid
):
"""
- Tests that the `USD` is the only valid currency for the Emeritus courses.
+ Tests that the `USD` is the only valid currency for the External courses.
"""
- emeritus_course = EmeritusCourse(emeritus_course_data)
- emeritus_course.list_currency = list_currency
- assert emeritus_course.validate_list_currency() == is_valid
+ keymap = get_keymap(external_course_data["course_run_code"])
+ external_course = ExternalCourse(external_course_data, keymap=keymap)
+ external_course.list_currency = list_currency
+ assert external_course.validate_list_currency() == is_valid
+@pytest.mark.parametrize(
+ "external_course_data",
+ [{"platform": EMERITUS_PLATFORM_NAME}, {"platform": GLOBAL_ALUMNI_PLATFORM_NAME}],
+ indirect=True,
+)
@pytest.mark.parametrize(
("end_date", "is_valid"),
[
@@ -810,10 +940,11 @@ def test_emeritus_course_validate_list_currency(
(now_in_utc() - timedelta(days=1), False),
],
)
-def test_emeritus_course_validate_end_date(emeritus_course_data, end_date, is_valid):
+def test_external_course_validate_end_date(external_course_data, end_date, is_valid):
"""
- Tests that the valid end date is in the future for Emeritus courses.
+ Tests that the valid end date is in the future for External courses.
"""
- emeritus_course = EmeritusCourse(emeritus_course_data)
- emeritus_course.end_date = end_date
- assert emeritus_course.validate_end_date() == is_valid
+ keymap = get_keymap(external_course_data["course_run_code"])
+ external_course = ExternalCourse(external_course_data, keymap=keymap)
+ external_course.end_date = end_date
+ assert external_course.validate_end_date() == is_valid
diff --git a/courses/sync_external_courses/test_data/batch_test.json b/courses/sync_external_courses/test_data/batch_test.json
index 90b8eed24..795b5b12f 100644
--- a/courses/sync_external_courses/test_data/batch_test.json
+++ b/courses/sync_external_courses/test_data/batch_test.json
@@ -114,8 +114,8 @@
"language": "English",
"image_name": "test_emeritus_image.jpg",
"ceu": "2.8",
- "landing_page_url": "https://test-emeritus-api.io/Internet-of-things-iot-design-and-applications?utm_medium=EmWebsite&utm_campaign=direct_EmWebsite?utm_campaign=school_website&utm_medium=website&utm_source=MIT-web",
- "Apply_now_url": "https://test-emeritus-api.io/?locale=en&program_sfid=01t2s000000OHA2AAO&source=applynowlp&utm_campaign=school&utm_medium=MITWebsite&utm_source=MIT-web",
+ "landing_page_url": "https://test-external-course-sync-api.io/Internet-of-things-iot-design-and-applications?utm_medium=EmWebsite&utm_campaign=direct_EmWebsite?utm_campaign=school_website&utm_medium=website&utm_source=MIT-web",
+ "Apply_now_url": "https://test-external-course-sync-api.io/?locale=en&program_sfid=01t2s000000OHA2AAO&source=applynowlp&utm_campaign=school&utm_medium=MITWebsite&utm_source=MIT-web",
"description": "By 2030, McKinsey Digital research estimates that it could enable USD 5.5 trillion to USD 12.6 trillion in value globally, including the value captured by consumers and customers of Internet of Things ( IoT) products and services. This future dominated by IoT requires a paradigm shift in the way products and services are designed. MIT xPRO’s Internet of Things (IoT): Design and Applications program is designed to provide you with a strategic road map for creating user-centered, future-ready, differentiated products that drive business growth. Through activities, assignments, and industry examples, you will learn the fundamental principles of developing IoT-centric products and services.\r",
"learning_outcomes": "This program will enable you to:\r\n● Understand how IoT mindsets are contributing to a global shift in product and business strategy\r\n● Discover how IoT impacts the design of both products and businesses\r\n● Identify hardware, software, and data technologies that support IoT adoption\r\n● Explore examples of successful IoT product and business strategies\r\n● Examine legal, ethical, privacy, and security concerns related to IoT",
"program_for": "The program is ideal for:\r\n● Managers and functional leaders looking to learn the strategies for successfully leveraging IoT principles to drive business value\r\n● Product managers and designers who want to shift to an IoT-centric mindset to develop innovative products and services\r\n● Technology professionals keen on understanding the fundamental principles associated with the implementation of IoT and its wide array of applications\r\nNote: This is a nontechnical program for which there are no prerequisites.\r"
@@ -183,8 +183,8 @@
"language": "English",
"image_name": "test_emeritus_image.jpg",
"ceu": "0.8",
- "landing_page_url": "https://test-emeritus-api.io/professional-certificate-cybersecurity?utm_campaign=school_website&utm_medium=website&utm_source=MIT-web",
- "Apply_now_url": "https://test-emeritus-api.io/?locale=en&program_sfid=01t2s000000ZdQKAA0&source=applynowlp&utm_campaign=school&utm_medium=MITWebsite&utm_source=MIT-web",
+ "landing_page_url": "https://test-external-course-sync-api.io/professional-certificate-cybersecurity?utm_campaign=school_website&utm_medium=website&utm_source=MIT-web",
+ "Apply_now_url": "https://test-external-course-sync-api.io/?locale=en&program_sfid=01t2s000000ZdQKAA0&source=applynowlp&utm_campaign=school&utm_medium=MITWebsite&utm_source=MIT-web",
"description": "Cyberattacks are becoming more frequent, complex, and targeted, collectively costing organizations billions of dollars annually. It’s no wonder that cybersecurity is one of the fastest growing industries; by 2027, Forbes projects the value of the cybersecurity market to reach USD 403 billion. More and more companies and government agencies are seeking to hire cybersecurity professionals with the specialized technical skills needed to defend mission-critical computer systems, networks, and cloud applications against cyberattacks. If you’re keen to step into this high-growth field and advance your career, the MIT xPRO Professional Certificate in Cybersecurity is for you.\r",
"learning_outcomes": "This program will enable you to:\r\n● Gain an overview of cybersecurity risk management, including its foundational concepts and relevant regulations\r\n● Explore the domains covering various aspects of cloud technology\r\n● Learn adversary tactics and techniques that are utilized as the foundational development of specific threat models and methodologies\r\n● Understand the guidelines for organizations to prepare themselves against cybersecurity attacks",
"program_for": "The program is ideal for:\r\n● Early-career IT professionals, network engineers, and system administrators wanting to gain a comprehensive overview of cybersecurity and fast-track their career progression\r\n● IT project managers and engineers keen on gaining the ability to think critically about the threat landscape, including vulnerabilities in cybersecurity, and upgrading their resume for career advancement\r\n● Mid- or later-career professionals seeking a career change and looking to add critical cybersecurity knowledge and foundational lessons to their resume"
diff --git a/courses/tasks.py b/courses/tasks.py
index a546c1acd..52c308d5e 100644
--- a/courses/tasks.py
+++ b/courses/tasks.py
@@ -9,10 +9,11 @@
from django.db.models import Q
from requests.exceptions import HTTPError
-from courses.models import CourseRun, CourseRunCertificate
-from courses.sync_external_courses.emeritus_api import (
- fetch_emeritus_courses,
- update_emeritus_course_runs,
+from courses.models import CourseRun, CourseRunCertificate, Platform
+from courses.sync_external_courses.external_course_sync_api import (
+ EXTERNAL_COURSE_VENDOR_KEYMAPS,
+ fetch_external_courses,
+ update_external_course_runs,
)
from courses.utils import (
ensure_course_run_grade,
@@ -114,11 +115,24 @@ def sync_courseruns_data():
@app.task
-def task_sync_emeritus_course_runs():
- """Task to sync Emeritus course runs"""
+def task_sync_external_course_runs():
+ """Task to sync external course runs"""
if not settings.FEATURES.get("ENABLE_EXTERNAL_COURSE_SYNC", False):
log.info("External Course sync is disabled.")
return
- emeritus_course_runs = fetch_emeritus_courses()
- update_emeritus_course_runs(emeritus_course_runs)
+ platforms = Platform.objects.filter(sync_daily=True)
+ for platform in platforms:
+ keymap = EXTERNAL_COURSE_VENDOR_KEYMAPS.get(platform.name.lower())
+ if not keymap:
+ log.exception(
+ "The platform '%s' does not have a sync API configured. Please disable the 'sync_daily' setting for this platform.",
+ platform.name,
+ )
+ continue
+ try:
+ keymap = keymap()
+ external_course_runs = fetch_external_courses(keymap)
+ update_external_course_runs(external_course_runs, keymap)
+ except Exception:
+ log.exception("Some error occurred")
diff --git a/courses/tasks_test.py b/courses/tasks_test.py
index a615c25f9..970b05268 100644
--- a/courses/tasks_test.py
+++ b/courses/tasks_test.py
@@ -4,8 +4,11 @@
import pytest
-from courses.factories import CourseRunFactory
-from courses.tasks import sync_courseruns_data, task_sync_emeritus_course_runs
+from courses.factories import CourseRunFactory, PlatformFactory
+from courses.sync_external_courses.external_course_sync_api import (
+ EMERITUS_PLATFORM_NAME,
+)
+from courses.tasks import sync_courseruns_data, task_sync_external_course_runs
pytestmark = [pytest.mark.django_db]
@@ -25,13 +28,25 @@ def test_sync_courseruns_data(mocker):
assert Counter(actual_course_runs) == Counter(course_runs)
-def test_task_sync_emeritus_course_runs(mocker, settings):
- """Test task_sync_emeritus_course_runs calls the right api functionality"""
+def test_task_sync_external_course_runs(mocker, settings):
+ """Test task_sync_external_course_runs to call APIs for supported platforms and skip unsupported ones in EXTERNAL_COURSE_VENDOR_KEYMAPS"""
settings.FEATURES["ENABLE_EXTERNAL_COURSE_SYNC"] = True
- mock_fetch_emeritus_courses = mocker.patch("courses.tasks.fetch_emeritus_courses")
- mock_update_emeritus_course_runs = mocker.patch(
- "courses.tasks.update_emeritus_course_runs"
+
+ mock_fetch_external_courses = mocker.patch("courses.tasks.fetch_external_courses")
+ mock_update_external_course_runs = mocker.patch(
+ "courses.tasks.update_external_course_runs"
+ )
+ mock_log = mocker.patch("courses.tasks.log")
+
+ PlatformFactory.create(name=EMERITUS_PLATFORM_NAME, sync_daily=True)
+ PlatformFactory.create(name="UnknownPlatform", sync_daily=True)
+
+ task_sync_external_course_runs.delay()
+
+ mock_fetch_external_courses.assert_called_once()
+ mock_update_external_course_runs.assert_called_once()
+
+ mock_log.exception.assert_called_once_with(
+ "The platform '%s' does not have a sync API configured. Please disable the 'sync_daily' setting for this platform.",
+ "UnknownPlatform",
)
- task_sync_emeritus_course_runs.delay()
- mock_fetch_emeritus_courses.assert_called_once()
- mock_update_emeritus_course_runs.assert_called_once()
diff --git a/courses/urls.py b/courses/urls.py
index 83f18942d..14dda4916 100644
--- a/courses/urls.py
+++ b/courses/urls.py
@@ -4,6 +4,7 @@
from rest_framework import routers
from courses.views import v1
+from courses.views.v1 import ExternalCourseListView
router = routers.SimpleRouter()
router.register(r"programs", v1.ProgramViewSet, basename="programs_api")
@@ -29,4 +30,9 @@
re_path(
r"^api/enrollments/", v1.UserEnrollmentsView.as_view(), name="user-enrollments"
),
+ path(
+ "api/external_courses//",
+ ExternalCourseListView.as_view(),
+ name="external_courses",
+ ),
]
diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py
index 0ee5dd328..86177ae15 100644
--- a/courses/views/v1/__init__.py
+++ b/courses/views/v1/__init__.py
@@ -4,7 +4,7 @@
from mitol.digitalcredentials.mixins import DigitalCredentialsRequestViewSetMixin
from rest_framework import status, viewsets
from rest_framework.authentication import SessionAuthentication
-from rest_framework.permissions import IsAuthenticated
+from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -27,6 +27,10 @@
ProgramEnrollmentSerializer,
ProgramSerializer,
)
+from courses.sync_external_courses.external_course_sync_api import (
+ EXTERNAL_COURSE_VENDOR_KEYMAPS,
+ fetch_external_courses,
+)
from ecommerce.models import Product
@@ -202,3 +206,34 @@ def get_queryset(self):
Returns parent topics with course count > 0.
"""
return CourseTopic.parent_topics_with_courses()
+
+
+class ExternalCourseListView(APIView):
+ """
+ ReadOnly View to list External courses.
+ """
+
+ permission_classes = [IsAdminUser]
+
+ def get(self, request, *args, **kwargs): # noqa: ARG002
+ """
+ Get External courses list from the External API and return it.
+ """
+
+ vendor = kwargs.get("vendor").replace("_", " ")
+ keymap = EXTERNAL_COURSE_VENDOR_KEYMAPS.get(vendor.lower())
+ if not keymap:
+ return Response(
+ {
+ "error": f"The vendor '{vendor}' is not supported. Supported vendors are {', '.join(EXTERNAL_COURSE_VENDOR_KEYMAPS)}"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ try:
+ data = fetch_external_courses(keymap())
+ return Response(data, status=status.HTTP_200_OK)
+ except Exception as e: # noqa: BLE001
+ return Response(
+ {"error": "Some error occurred.", "details": str(e)},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
diff --git a/courses/views_test.py b/courses/views_test.py
index 2c735eac6..99b6e6864 100644
--- a/courses/views_test.py
+++ b/courses/views_test.py
@@ -2,8 +2,10 @@
Tests for course views
"""
+import json
import operator as op
from datetime import timedelta
+from pathlib import Path
import pytest
from django.contrib.auth.models import AnonymousUser
@@ -35,6 +37,10 @@
ProgramCertificateSerializer,
ProgramSerializer,
)
+from courses.sync_external_courses.external_course_sync_api import (
+ EMERITUS_PLATFORM_NAME,
+ GLOBAL_ALUMNI_PLATFORM_NAME,
+)
from ecommerce.factories import ProductFactory, ProductVersionFactory
from mitxpro.test_utils import assert_drf_json_equal
from mitxpro.utils import now_in_utc
@@ -611,3 +617,40 @@ def test_course_topics_api(client, django_assert_num_queries):
assert len(resp_json) == 1
assert resp_json[0]["name"] == parent_topic.name
assert resp_json[0]["course_count"] == 4
+
+
+@pytest.mark.parametrize("expected_status_code", [200, 500])
+@pytest.mark.parametrize(
+ "vendor_name", [EMERITUS_PLATFORM_NAME, GLOBAL_ALUMNI_PLATFORM_NAME]
+)
+def test_external_course_list_view(
+ admin_drf_client, mocker, expected_status_code, vendor_name
+):
+ """
+ Test that the External API List calls fetch_external_courses and returns its mocked response.
+ """
+ if expected_status_code == 200:
+ with Path(
+ "courses/sync_external_courses/test_data/batch_test.json"
+ ).open() as test_data_file:
+ mocked_response = json.load(test_data_file)["rows"]
+
+ patched_fetch_external_courses = mocker.patch(
+ "courses.views.v1.fetch_external_courses", return_value=mocked_response
+ )
+ else:
+ patched_fetch_external_courses = mocker.patch(
+ "courses.views.v1.fetch_external_courses",
+ side_effect=Exception("Some error occurred."),
+ )
+ mocked_response = {
+ "error": "Some error occurred.",
+ "details": "Some error occurred.",
+ }
+
+ response = admin_drf_client.get(
+ reverse("external_courses", kwargs={"vendor": vendor_name})
+ )
+ assert response.json() == mocked_response
+ assert response.status_code == expected_status_code
+ patched_fetch_external_courses.assert_called_once()
diff --git a/courseware/api.py b/courseware/api.py
index 9fef93ac9..251ab97ed 100644
--- a/courseware/api.py
+++ b/courseware/api.py
@@ -52,7 +52,6 @@
OPENEDX_REGISTER_USER_PATH = "/user_api/v1/account/registration/"
OPENEDX_REQUEST_DEFAULTS = dict(country="US", honor_code=True) # noqa: C408
-OPENEDX_SOCIAL_LOGIN_XPRO_PATH = "/auth/login/mitxpro-oauth2/?auth_entry=login"
OPENEDX_OAUTH2_AUTHORIZE_PATH = "/oauth2/authorize"
OPENEDX_OAUTH2_ACCESS_TOKEN_PATH = "/oauth2/access_token" # noqa: S105
OPENEDX_OAUTH2_SCOPES = ["read", "write"]
@@ -194,7 +193,7 @@ def create_edx_user(user):
username=user.username,
email=user.email,
name=user.name,
- provider=settings.MITXPRO_OAUTH_PROVIDER,
+ provider=settings.OPENEDX_OAUTH_PROVIDER,
access_token=access_token.token,
**OPENEDX_REQUEST_DEFAULTS,
),
@@ -256,7 +255,7 @@ def create_edx_auth_token(user):
req_session.cookies.set_cookie(session_cookie)
# Step 3
- url = edx_url(OPENEDX_SOCIAL_LOGIN_XPRO_PATH)
+ url = edx_url(settings.OPENEDX_SOCIAL_LOGIN_PATH)
resp = req_session.get(url)
resp.raise_for_status()
@@ -316,7 +315,7 @@ def update_edx_user_email(user):
)
req_session.cookies.set_cookie(session_cookie)
- url = edx_url(OPENEDX_SOCIAL_LOGIN_XPRO_PATH)
+ url = edx_url(settings.OPENEDX_SOCIAL_LOGIN_PATH)
resp = req_session.get(url)
resp.raise_for_status()
@@ -815,7 +814,7 @@ def create_oauth_application():
defaults=dict( # noqa: C408
redirect_uris=urljoin(
settings.OPENEDX_BASE_REDIRECT_URL,
- f"/auth/complete/{settings.MITXPRO_OAUTH_PROVIDER}/",
+ f"/auth/complete/{settings.OPENEDX_OAUTH_PROVIDER}/",
),
client_type="confidential",
authorization_grant_type="authorization-code",
diff --git a/courseware/api_test.py b/courseware/api_test.py
index cea22306f..190e13cfe 100644
--- a/courseware/api_test.py
+++ b/courseware/api_test.py
@@ -67,7 +67,7 @@ def application(settings):
"""Test data and settings needed for create_edx_user tests"""
settings.OPENEDX_OAUTH_APP_NAME = "test_app_name"
settings.OPENEDX_API_BASE_URL = "http://example.com"
- settings.MITXPRO_OAUTH_PROVIDER = "test_provider"
+ settings.OPENEDX_OAUTH_PROVIDER = "test_provider"
settings.MITXPRO_REGISTRATION_ACCESS_TOKEN = "access_token" # noqa: S105
return Application.objects.create(
name=settings.OPENEDX_OAUTH_APP_NAME,
@@ -174,7 +174,7 @@ def create_token_responses(settings):
code = "ghi789"
responses.add(
responses.GET,
- f"{settings.OPENEDX_API_BASE_URL}/auth/login/mitxpro-oauth2/?auth_entry=login",
+ f"{settings.OPENEDX_API_BASE_URL}{settings.OPENEDX_SOCIAL_LOGIN_PATH}",
status=status.HTTP_200_OK,
)
responses.add(
@@ -243,7 +243,7 @@ def test_create_edx_user(user, settings, application, access_token_count):
"username": user.username,
"email": user.email,
"name": user.name,
- "provider": settings.MITXPRO_OAUTH_PROVIDER,
+ "provider": settings.OPENEDX_OAUTH_PROVIDER,
"access_token": created_access_token.token,
"country": "US",
"honor_code": "True",
@@ -350,7 +350,7 @@ def test_update_edx_user_email(settings, user):
code = "ghi789"
responses.add(
responses.GET,
- f"{settings.OPENEDX_API_BASE_URL}/auth/login/mitxpro-oauth2/?auth_entry=login",
+ f"{settings.OPENEDX_API_BASE_URL}{settings.OPENEDX_SOCIAL_LOGIN_PATH}",
status=status.HTTP_200_OK,
)
responses.add(
diff --git a/docs/configure_open_edx.md b/docs/configure_open_edx.md
deleted file mode 100644
index 1fef4b896..000000000
--- a/docs/configure_open_edx.md
+++ /dev/null
@@ -1,177 +0,0 @@
-## Configure Open edX
-
-In order to create user accounts in Open edX and permit authentication from xPro to Open edX, you need to configure xPro as an OAuth2 provider for Open edX.
-
-#### Setup Open edX Devstack
-
-Following steps are inspired by [edx-devstack](https://github.com/edx/devstack).
-
-#### Clone edx/devstack
-
-```
-$ mkdir openedx
-$ cd openedx
-$ git clone https://github.com/edx/devstack
-$ cd devstack
-$ git checkout open-release/maple.master
-$ make requirements
-$ export OPENEDX_RELEASE=maple.master
-$ make dev.clone
-```
-
-#### Clone and checkout edx-platform (if not already).
-
-```
-$ cd ..
-$ git clone https://github.com/mitodl/edx-platform
-$ cd edx-platform
-$ git checkout xpro/maple
-```
-
-#### Pull latest images and run provision
-
-```
-$ cd devstack
-$ make pull
-$ make dev.provision
-```
-
-#### Start your servers
-
-`make dev.up`
-
-#### Stop your servers
-
-`make stop`
-
-### Setup social auth
-
-#### Install `social-auth-mitxpro` in LMS
-
-There are two options for this:
-
-##### Install via pip
-
-- `pip install social-auth-mitxpro`
-
-##### Install from local Build
-
-- Checkout the [social-auth-mitxpro](https://github.com/mitodl/social-auth-mitxpro) project and build the package per the project instructions
-- Copy the `social-auth-mitxpro-$VERSION.tar.gz` file into devstack's `edx-platform` directory
-- In devstack, run `make lms-shell` and within that shell `pip install social-auth-mitxpro-$VERSION.tar.gz`
- - To update to a new development version without having to actually bump the package version, simply `pip uninstall social-auth-mitxpro`, then install again
-
-#### Install `openedx-companion-auth` in LMS
-
-There are two options for this:
-
-##### Install via pip
-
-- `pip install openedx-companion-auth`
-
-##### Install from local Build
-
-- Checkout the [openedx-companion-auth](https://github.com/mitodl/open-edx-plugins/tree/main/src/openedx_companion_auth) project and build the package as per the project instructions
-- Copy the `openedx-companion-auth-$VERSION.tar.gz` file from `dist` folder into devstack's `edx-platform` directory
-- In devstack, run `make lms-shell` and within that shell `pip install openedx-companion-auth-$VERSION.tar.gz`
- - To update to a new development version without having to actually bump the package version, simply `pip uninstall -y openedx-companion-auth`, then install again
-
-#### Configure xPro as a OAuth provider for Open edX
-
-In xPro:
-
-- go to `/admin/oauth2_provider/application/` and create a new application with these settings selected:
-
- - `Redirect uris`: `http://:18000/auth/complete/mitxpro-oauth2/`
-
- - _[OSX users]_ You will need redirect uris for both the local edX host alias and for `host.docker.internal`. This value should be:
-
- ```shell
- http://edx.odl.local:18000/auth/complete/mitxpro-oauth2/
- http://host.docker.internal:18000/auth/complete/mitxpro-oauth2/
- ```
-
- - _[Linux users]_ You will need redirect uris for both the local edX host alias and for the gateway IP of the docker-compose networking setup for xPro as found via `docker network inspect mitxpro_default`
-
- ```shell
- http://edx.odl.local:18000/auth/complete/mitxpro-oauth2/
- http://:18000/auth/complete/mitxpro-oauth2/
- # `GATEWAY_IP` should be something like `172.19.0.1`.
- ```
-
- - `Client type`: "Confidential"
- - `Authorization grant type`: "Authorization code"
- - `Skip authorization`: checked
- - Other values are arbitrary but be sure to fill them all out. Save the client id and secret for later
-
-In Open edX (derived from instructions [here](https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/tpa/tpa_integrate_open/tpa_oauth.html#additional-oauth2-providers-advanced)):
-
-- `make lms-shell` into the LMS container and ensure the following settings:
- - `/edx/etc/lms.yml`:
- ```
- FEATURES:
- ALLOW_PUBLIC_ACCOUNT_CREATION: true
- ENABLE_COMBINED_LOGIN_REGISTRATION: true
- ENABLE_OAUTH2_PROVIDER: true
- ENABLE_THIRD_PARTY_AUTH: true
- ...
- REGISTRATION_EXTRA_FIELDS:
- ...
- country: hidden
- ...
- SOCIAL_AUTH_OAUTH_SECRETS:
- mitxpro-oauth2:
- THIRD_PARTY_AUTH_BACKENDS:
- - social_auth_mitxpro.backends.MITxProOAuth2
- ```
-- `make lms-restart` to pick up the configuration changes
-- Login to django-admin, go to `http://:18000/admin/third_party_auth/oauth2providerconfig/`, and create a new config:
- - Select the default example site
- - The slug field **MUST** match the `Backend.name`, which for us is `
-mitxpro-oauth2`
- - Client Id should be the client id from the xPro Django Oauth Toolkit Application
- - Check the following checkboxes:
- - Skip hinted login dialog
- - Skip registration form
- - Sync learner profile data
- - Enable SSO id verification
- - In "Other settings", put:
- ```
- {
- "AUTHORIZATION_URL": "http://:8053/oauth2/authorize/",
- "ACCESS_TOKEN_URL": "http://:8053/oauth2/token/",
- "API_ROOT": "http://:8053/"
- }
- ```
- - `LOCAL_XPRO_ALIAS` should be your `/etc/hosts` alias for the mitxpro app
- - `EXTERNAL_XPRO_HOST` will depend on your OS, but it needs to be resolvable within the edx container
- - Linux users: The gateway IP of the docker-compose networking setup for xPro as found via `docker network inspect mitxpro_default`
- - OSX users: Use `host.docker.internal`
-
-#### Configure Open edX to support OAuth2 authentication from xPro
-
-- In Open edX:
- - go to `/admin/oauth2_provider/application/` and verify that an application named 'edx-oauth-app' exists with these settings:
- - `Redirect uris`: `http://xpro.odl.local:8053/login/_private/complete`
- - `Client type`: "Confidential"
- - `Authorization grant type`: "Authorization code"
- - `Skip authorization`: checked
- - Other values are arbitrary but be sure to fill them all out. Save the client id and secret for later
-- In xPro:
- - Set `OPENEDX_API_CLIENT_ID` to the client id
- - Set `OPENEDX_API_CLIENT_SECRET` to the client secret
-
-#### Configure Logout
-
-- In Open edX, configure `settings.IDA_LOGOUT_URI_LIST` to be a list including the full url to `://[:]/logout` in xPro
-
- - For devstack, this means modifying the value in `edx-platform/lms/envs/devstack.py` to include `http://xpro.odl.local:8053/logout`
- - For production, this setting can go in `lms.env.json` under the key `IDA_LOGOUT_URI_LIST` as a JSON array of with that string in it
-
-- xPro:
- - Set `LOGOUT_REDIRECT_URL` to the full path to the edx `/logout` view.
- - For local development this will be `http://:18000/logout`
-
-#### Configure Open edX user and token for use with xPro management commands
-
-- In Open edX, create a staff user and then under `/admin/oauth2_provider/accesstoken/` add access token. The value of said token needs to match the value set for the `OPENEDX_SERVICE_WORKER_API_TOKEN` key in the xPro app.
diff --git a/ecommerce/api_test.py b/ecommerce/api_test.py
index 31eaf3f67..dc62a74ad 100644
--- a/ecommerce/api_test.py
+++ b/ecommerce/api_test.py
@@ -117,7 +117,7 @@
@pytest.fixture(autouse=True)
-def cybersource_settings(settings): # noqa: PT004
+def cybersource_settings(settings):
"""
Set cybersource settings
"""
@@ -1219,7 +1219,7 @@ def test_validate_basket_product_requires_enrollment_code(basket_and_coupons):
def test_apply_coupon_to_product_requires_enrollment_code(user, basket_and_coupons):
"""
- if product that requires enrollment code, a promo coupon is not valid and can't be applied in checkout;
+ If product that requires enrollment code, a promo coupon is not valid and can't be applied in checkout;
An enrollment code is valid if it's eligible for the product
"""
diff --git a/ecommerce/models_test.py b/ecommerce/models_test.py
index 6a91f3ed8..7d16507b0 100644
--- a/ecommerce/models_test.py
+++ b/ecommerce/models_test.py
@@ -176,7 +176,7 @@ def test_type_string():
def test_title():
"""
- title should return a string representation of the Product's title
+ Title should return a string representation of the Product's title
"""
program = ProgramFactory.create(title="test title of the program")
course = CourseFactory.create(title="test title of the course")
diff --git a/ecommerce/views_test.py b/ecommerce/views_test.py
index 6f3be8076..c38380223 100644
--- a/ecommerce/views_test.py
+++ b/ecommerce/views_test.py
@@ -92,7 +92,7 @@ def render_json(serializer):
@pytest.fixture(autouse=True)
-def ecommerce_settings(settings): # noqa: PT004
+def ecommerce_settings(settings):
"""
Set cybersource settings
"""
diff --git a/fixtures/autouse.py b/fixtures/autouse.py
index 48859038f..819e8c8ba 100644
--- a/fixtures/autouse.py
+++ b/fixtures/autouse.py
@@ -4,6 +4,6 @@
@pytest.fixture(autouse=True)
-def disable_hubspot_api(settings): # noqa: PT004
+def disable_hubspot_api(settings):
"""Disable Hubspot API by default for tests"""
settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN = None
diff --git a/fixtures/common.py b/fixtures/common.py
index 0dc6a3095..b74334dfc 100644
--- a/fixtures/common.py
+++ b/fixtures/common.py
@@ -107,7 +107,7 @@ def valid_address_dict():
@pytest.fixture
-def nplusone_fail(settings): # noqa: PT004
+def nplusone_fail(settings):
"""Configures the nplusone app to raise errors"""
settings.NPLUSONE_RAISE = True
diff --git a/hubspot_xpro/api.py b/hubspot_xpro/api.py
index ac8344ee9..1311f7c19 100644
--- a/hubspot_xpro/api.py
+++ b/hubspot_xpro/api.py
@@ -421,7 +421,7 @@ def get_hubspot_id_for_object(
return hubspot_obj.id
elif raise_error:
raise ValueError(
- "Hubspot id could not be found for %s for id %d"
+ "Hubspot id could not be found for %s for id %d" # noqa: UP031
% (content_type.name, obj.id)
)
diff --git a/mail/api_test.py b/mail/api_test.py
index 46632e197..696fba0e3 100644
--- a/mail/api_test.py
+++ b/mail/api_test.py
@@ -24,7 +24,7 @@
@pytest.fixture
-def email_settings(settings): # noqa: PT004
+def email_settings(settings):
"""Default settings for email tests"""
settings.MAILGUN_RECIPIENT_OVERRIDE = None
diff --git a/mitxpro/management/commands/configure_instance.py b/mitxpro/management/commands/configure_instance.py
index 41b45cebc..8ebabeba5 100644
--- a/mitxpro/management/commands/configure_instance.py
+++ b/mitxpro/management/commands/configure_instance.py
@@ -114,8 +114,8 @@ def handle(self, *args, **kwargs): # noqa: ARG002
if kwargs["platform"] == "macos":
redirects = "\n".join(
[
- f"http://{edx_host}/auth/complete/mitxpro-oauth2/",
- f"http://host.docker.internal{edx_gateway_port}/auth/complete/mitxpro-oauth2/",
+ f"http://{edx_host}/auth/complete/ol-oauth2/",
+ f"http://host.docker.internal{edx_gateway_port}/auth/complete/ol-oauth2/",
]
)
else:
@@ -129,8 +129,8 @@ def handle(self, *args, **kwargs): # noqa: ARG002
redirects = "\n".join(
[
- f"http://{edx_host}/auth/complete/mitxpro-oauth2/",
- f"http://{kwargs['gateway']}{edx_gateway_port}/auth/complete/mitxpro-oauth2/",
+ f"http://{edx_host}/auth/complete/ol-oauth2/",
+ f"http://{kwargs['gateway']}{edx_gateway_port}/auth/complete/ol-oauth2/",
]
)
diff --git a/mitxpro/settings.py b/mitxpro/settings.py
index fc81325d3..841c5464c 100644
--- a/mitxpro/settings.py
+++ b/mitxpro/settings.py
@@ -26,7 +26,7 @@
from mitxpro.celery_utils import OffsettingSchedule
from mitxpro.sentry import init_sentry
-VERSION = "0.164.1"
+VERSION = "0.166.0"
env.reset()
@@ -761,14 +761,14 @@
)
CRON_EXTERNAL_COURSERUN_SYNC_HOURS = get_string(
- name="CRON_EMERITUS_COURSERUN_SYNC_HOURS",
+ name="CRON_EXTERNAL_COURSERUN_SYNC_HOURS",
default="0",
- description="'hours' value for the 'sync-emeritus-course-runs' scheduled task (defaults to midnight)",
+ description="'hours' value for the 'sync-external-course-runs' scheduled task (defaults to midnight)",
)
CRON_EXTERNAL_COURSERUN_SYNC_DAYS = get_string(
- name="CRON_EMERITUS_COURSERUN_SYNC_DAYS",
+ name="CRON_EXTERNAL_COURSERUN_SYNC_DAYS",
default=None,
- description="'day_of_week' value for 'sync-emeritus-course-runs' scheduled task (default will run once a day).",
+ description="'day_of_week' value for 'sync-external-course-runs' scheduled task (default will run once a day).",
)
CRON_BASKET_DELETE_HOURS = get_string(
@@ -885,8 +885,8 @@
month_of_year="*",
),
},
- "sync-emeritus-course-runs": {
- "task": "courses.tasks.task_sync_emeritus_course_runs",
+ "sync-external-course-runs": {
+ "task": "courses.tasks.task_sync_external_course_runs",
"schedule": crontab(
minute="0",
hour=CRON_EXTERNAL_COURSERUN_SYNC_HOURS,
@@ -1070,7 +1070,18 @@
MITOL_AUTHENTICATION_REPLY_TO_EMAIL = MITXPRO_REPLY_TO_ADDRESS
-MITXPRO_OAUTH_PROVIDER = "mitxpro-oauth2"
+OPENEDX_OAUTH_PROVIDER = get_string(
+ name="OPENEDX_OAUTH_PROVIDER",
+ default="mitxpro-oauth2",
+ description="Social auth oauth provider backend name",
+)
+
+OPENEDX_SOCIAL_LOGIN_PATH = get_string(
+ name="OPENEDX_SOCIAL_LOGIN_PATH",
+ default="/auth/login/mitxpro-oauth2/?auth_entry=login",
+ description="Open edX social auth login url",
+)
+
OPENEDX_OAUTH_APP_NAME = get_string(
name="OPENEDX_OAUTH_APP_NAME",
default="edx-oauth-app",
@@ -1128,21 +1139,21 @@
description="Timeout (in seconds) for requests made via the edX API client",
)
-EMERITUS_API_KEY = get_string(
- name="EMERITUS_API_KEY",
+EXTERNAL_COURSE_SYNC_API_KEY = get_string(
+ name="EXTERNAL_COURSE_SYNC_API_KEY",
default=None,
- description="The API Key for Emeritus API",
+ description="The API Key for external course sync API",
required=True,
)
-EMERITUS_API_BASE_URL = get_string(
- name="EMERITUS_API_BASE_URL",
+EXTERNAL_COURSE_SYNC_API_BASE_URL = get_string(
+ name="EXTERNAL_COURSE_SYNC_API_BASE_URL",
default="https://mit-xpro.emeritus-analytics.io/",
- description="Base API URL for Emeritus API",
+ description="Base API URL for external course sync API",
)
-EMERITUS_API_REQUEST_TIMEOUT = get_int(
- name="EMERITUS_API_TIMEOUT",
+EXTERNAL_COURSE_SYNC_API_REQUEST_TIMEOUT = get_int(
+ name="EXTERNAL_COURSE_SYNC_API_REQUEST_TIMEOUT",
default=60,
- description="API request timeout for Emeritus APIs in seconds",
+ description="API request timeout for external course sync APIs in seconds",
)
# django debug toolbar only in debug mode
diff --git a/package.json b/package.json
index 8f37e8568..14ae0f441 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,7 @@
"flow-bin": "^0.95.1",
"flow-typed": "^3.2.1",
"formik": "^2.0.0",
- "history": "^4.6.3",
+ "history": "^5.0.0",
"hls.js": "^0.14.0",
"isomorphic-fetch": "^2.2.1",
"jquery": "^3.5.1",
@@ -99,7 +99,7 @@
"redux-logger": "^3.0.6",
"redux-query": "^2.3.1",
"reselect": "^4.0.0",
- "sass": "~1.81.0",
+ "sass": "~1.64.2",
"sass-lint": "^1.13.1",
"sass-loader": "^12.1.0",
"serialize-javascript": "^3.1.0",
diff --git a/renovate.json b/renovate.json
index 4920a18a3..efdca6f74 100644
--- a/renovate.json
+++ b/renovate.json
@@ -1,4 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": ["local>mitodl/.github:renovate-config"]
+ "extends": ["local>mitodl/.github:renovate-config"],
+ "packageRules": [
+ {
+ "matchPackages": ["sass"],
+ "automerge": false
+ }
+ ]
}
diff --git a/sheets/conftest.py b/sheets/conftest.py
index f681f7176..6ea01c306 100644
--- a/sheets/conftest.py
+++ b/sheets/conftest.py
@@ -4,7 +4,7 @@
@pytest.fixture(autouse=True)
-def sheets_settings(settings): # noqa: PT004
+def sheets_settings(settings):
"""Default settings for sheets tests"""
settings.FEATURES["COUPON_SHEETS"] = True
settings.SHEETS_REQ_EMAIL_COL = 7
diff --git a/sheets/coupon_request_api_test.py b/sheets/coupon_request_api_test.py
index 59970b40b..b1d790d15 100644
--- a/sheets/coupon_request_api_test.py
+++ b/sheets/coupon_request_api_test.py
@@ -19,7 +19,7 @@
@pytest.fixture
-def courseware_objects(): # noqa: PT004
+def courseware_objects():
"""Database objects that CSV data depends on"""
run = CourseRunFactory.create(courseware_id="course-v1:edX+DemoX+Demo_Course")
ProductVersionFactory.create(product__content_object=run)
diff --git a/static/js/containers/pages/login/LoginEmailPage_test.js b/static/js/containers/pages/login/LoginEmailPage_test.js
index 2e871c1f6..11cce53ad 100644
--- a/static/js/containers/pages/login/LoginEmailPage_test.js
+++ b/static/js/containers/pages/login/LoginEmailPage_test.js
@@ -77,8 +77,7 @@ describe("LoginEmailPage", () => {
{ email },
{ setSubmitting: setSubmittingStub, setErrors: setErrorsStub },
);
-
- assert.lengthOf(helper.browserHistory, 1);
+ assert.equal(helper.browserHistory.index, 0);
sinon.assert.calledWith(setErrorsStub, fieldErrors);
sinon.assert.calledWith(setSubmittingStub, false);
});
@@ -100,7 +99,7 @@ describe("LoginEmailPage", () => {
{ setSubmitting: setSubmittingStub, setErrors: setErrorsStub },
);
- assert.lengthOf(helper.browserHistory, 2);
+ assert.equal(helper.browserHistory.index, 1);
assert.include(helper.browserHistory.location, {
pathname: routes.login.password,
search: "",
diff --git a/static/js/containers/pages/login/LoginForgotPasswordConfirmPage_test.js b/static/js/containers/pages/login/LoginForgotPasswordConfirmPage_test.js
index c8648c69f..93a4bcb23 100644
--- a/static/js/containers/pages/login/LoginForgotPasswordConfirmPage_test.js
+++ b/static/js/containers/pages/login/LoginForgotPasswordConfirmPage_test.js
@@ -86,7 +86,7 @@ describe("LoginForgotPasswordConfirmPage", () => {
},
);
- assert.lengthOf(helper.browserHistory, 2);
+ assert.equal(helper.browserHistory.index, 1);
assert.include(helper.browserHistory.location, {
pathname: expectedUrl,
search: "",
diff --git a/static/js/containers/pages/login/LoginForgotPasswordPage_test.js b/static/js/containers/pages/login/LoginForgotPasswordPage_test.js
index e054fcd3e..ed0b55bce 100644
--- a/static/js/containers/pages/login/LoginForgotPasswordPage_test.js
+++ b/static/js/containers/pages/login/LoginForgotPasswordPage_test.js
@@ -63,7 +63,7 @@ describe("LoginForgotPasswordPage", () => {
},
);
- assert.lengthOf(helper.browserHistory, 2);
+ assert.equal(helper.browserHistory.index, 1);
assert.include(helper.browserHistory.location, {
pathname: routes.root,
search: "",
diff --git a/static/js/containers/pages/login/LoginPasswordPage_test.js b/static/js/containers/pages/login/LoginPasswordPage_test.js
index 23317eb43..7cf991092 100644
--- a/static/js/containers/pages/login/LoginPasswordPage_test.js
+++ b/static/js/containers/pages/login/LoginPasswordPage_test.js
@@ -69,7 +69,7 @@ describe("LoginPasswordPage", () => {
{ setSubmitting: setSubmittingStub, setErrors: setErrorsStub },
);
- assert.lengthOf(helper.browserHistory, 1);
+ assert.equal(helper.browserHistory.index, 0);
sinon.assert.calledWith(setErrorsStub, fieldErrors);
sinon.assert.calledWith(setSubmittingStub, false);
});
diff --git a/static/js/containers/pages/profile/EditProfilePage_test.js b/static/js/containers/pages/profile/EditProfilePage_test.js
index e046c7f93..6652139ab 100644
--- a/static/js/containers/pages/profile/EditProfilePage_test.js
+++ b/static/js/containers/pages/profile/EditProfilePage_test.js
@@ -122,7 +122,7 @@ describe("EditProfilePage", () => {
assert.equal(setErrors.length, 0);
assert.isNull(helper.currentLocation);
} else {
- assert.equal(helper.currentLocation.pathname, "/profile/");
+ assert.equal(helper.currentLocation.location.pathname, "/profile/");
}
});
});
diff --git a/static/js/containers/pages/register/RegisterConfirmPage_test.js b/static/js/containers/pages/register/RegisterConfirmPage_test.js
index d83218864..add91c9d6 100644
--- a/static/js/containers/pages/register/RegisterConfirmPage_test.js
+++ b/static/js/containers/pages/register/RegisterConfirmPage_test.js
@@ -58,8 +58,14 @@ describe("RegisterConfirmPage", () => {
},
},
});
- assert.equal(helper.currentLocation.pathname, "/create-account/details/");
- assert.equal(helper.currentLocation.search, `?partial_token=${token}`);
+ assert.equal(
+ helper.currentLocation.location.pathname,
+ "/create-account/details/",
+ );
+ assert.equal(
+ helper.currentLocation.location.search,
+ `?partial_token=${token}`,
+ );
});
it("Shows a register link with invalid/expired confirmation code", async () => {
diff --git a/static/js/containers/pages/register/RegisterDetailsPage_test.js b/static/js/containers/pages/register/RegisterDetailsPage_test.js
index 383d76931..ab42465f6 100644
--- a/static/js/containers/pages/register/RegisterDetailsPage_test.js
+++ b/static/js/containers/pages/register/RegisterDetailsPage_test.js
@@ -89,15 +89,15 @@ describe("RegisterDetailsPage", () => {
{ body, headers: undefined, credentials: undefined },
);
- assert.lengthOf(helper.browserHistory, 1);
+ assert.equal(helper.browserHistory.index, 0);
sinon.assert.calledWith(setErrorsStub, fieldErrors);
sinon.assert.calledWith(setSubmittingStub, false);
});
//
[
- [STATE_ERROR_TEMPORARY, [], routes.register.error, ""],
- [STATE_ERROR, [], routes.register.error, ""], // cover the case with an error but no messages
+ [STATE_ERROR_TEMPORARY, [], routes.register.error, "?"],
+ [STATE_ERROR, [], routes.register.error, "?"], // cover the case with an error but no messages
[
STATE_REGISTER_EXTRA_DETAILS,
[],
@@ -110,7 +110,7 @@ describe("RegisterDetailsPage", () => {
routes.register.denied,
"?error=error_code",
],
- [STATE_USER_BLOCKED, [], routes.register.denied, ""],
+ [STATE_USER_BLOCKED, [], routes.register.denied, "?"],
].forEach(([state, errors, pathname, search]) => {
it(`redirects to ${pathname} when it receives auth state ${state}`, async () => {
const { inner } = await renderPage();
@@ -137,7 +137,7 @@ describe("RegisterDetailsPage", () => {
{ body, headers: undefined, credentials: undefined },
);
- assert.lengthOf(helper.browserHistory, 2);
+ assert.equal(helper.browserHistory.index, 1);
assert.include(helper.browserHistory.location, {
pathname,
search,
diff --git a/static/js/containers/pages/register/RegisterEmailPage_test.js b/static/js/containers/pages/register/RegisterEmailPage_test.js
index dcc66da38..49f48e48a 100644
--- a/static/js/containers/pages/register/RegisterEmailPage_test.js
+++ b/static/js/containers/pages/register/RegisterEmailPage_test.js
@@ -71,7 +71,7 @@ describe("RegisterEmailPage", () => {
{ setSubmitting: setSubmittingStub, setErrors: setErrorsStub },
);
- assert.lengthOf(helper.browserHistory, 1);
+ assert.equal(helper.browserHistory.index, 0);
sinon.assert.calledWith(setErrorsStub, fieldErrors);
sinon.assert.calledWith(setSubmittingStub, false);
});
@@ -92,7 +92,7 @@ describe("RegisterEmailPage", () => {
{ setSubmitting: setSubmittingStub, setErrors: setErrorsStub },
);
- assert.lengthOf(helper.browserHistory, 2);
+ assert.equal(helper.browserHistory.index, 1);
assert.include(helper.browserHistory.location, {
pathname: routes.login.password,
search: "",
@@ -173,7 +173,7 @@ describe("RegisterEmailPage", () => {
{ setSubmitting: setSubmittingStub, setErrors: setErrorsStub },
);
- assert.lengthOf(helper.browserHistory, 2);
+ assert.equal(helper.browserHistory.index, 1);
assert.include(helper.browserHistory.location, {
pathname: routes.register.confirmSent,
search: `?email=${encodeURIComponent(email)}`,
diff --git a/static/js/containers/pages/register/RegisterExtraDetailsPage_test.js b/static/js/containers/pages/register/RegisterExtraDetailsPage_test.js
index 7a3473049..14f543771 100644
--- a/static/js/containers/pages/register/RegisterExtraDetailsPage_test.js
+++ b/static/js/containers/pages/register/RegisterExtraDetailsPage_test.js
@@ -102,7 +102,7 @@ describe("RegisterExtraDetailsPage", () => {
{ body, headers: undefined, credentials: undefined },
);
- assert.lengthOf(helper.browserHistory, 1);
+ assert.equal(helper.browserHistory.index, 0);
sinon.assert.calledWith(setErrorsStub, fieldErrors);
sinon.assert.calledWith(setSubmittingStub, false);
});
@@ -138,15 +138,15 @@ describe("RegisterExtraDetailsPage", () => {
//
[
- [STATE_ERROR_TEMPORARY, [], routes.register.error, ""],
- [STATE_ERROR, [], routes.register.error, ""], // cover the case with an error but no messages
+ [STATE_ERROR_TEMPORARY, [], routes.register.error, "?"],
+ [STATE_ERROR, [], routes.register.error, "?"], // cover the case with an error but no messages
[
STATE_USER_BLOCKED,
["error_code"],
routes.register.denied,
"?error=error_code",
],
- [STATE_USER_BLOCKED, [], routes.register.denied, ""],
+ [STATE_USER_BLOCKED, [], routes.register.denied, "?"],
].forEach(([state, errors, pathname, search]) => {
it(`redirects to ${pathname} when it receives auth state ${state}`, async () => {
const { inner } = await renderPage();
diff --git a/static/js/containers/pages/settings/AccountSettingsPage_test.js b/static/js/containers/pages/settings/AccountSettingsPage_test.js
index c9234459e..ac760065a 100644
--- a/static/js/containers/pages/settings/AccountSettingsPage_test.js
+++ b/static/js/containers/pages/settings/AccountSettingsPage_test.js
@@ -87,7 +87,7 @@ describe("AccountSettingsPage", () => {
},
);
- assert.lengthOf(helper.browserHistory, 2);
+ assert.equal(helper.browserHistory.index, 1);
assert.include(helper.browserHistory.location, {
pathname: expectedUrl,
search: "",
@@ -157,7 +157,7 @@ describe("AccountSettingsPage", () => {
},
);
- assert.lengthOf(helper.browserHistory, 2);
+ assert.equal(helper.browserHistory.index, 1);
assert.include(helper.browserHistory.location, {
pathname: expectedUrl,
search: "",
diff --git a/static/scss/detail/course-overview.scss b/static/scss/detail/course-overview.scss
new file mode 100644
index 000000000..0b3a611b1
--- /dev/null
+++ b/static/scss/detail/course-overview.scss
@@ -0,0 +1,18 @@
+// sass-lint:disable mixins-before-declarations
+@import "common";
+
+.course-overview-block {
+ background: white url("#{$static-path}/images/dotted-bg.png") repeat-x 0 0;
+
+ .course-overview-heading {
+ margin: 0 auto;
+ max-width: 1000px;
+ text-align: center;
+ }
+
+ h1 {
+ max-width: 700px;
+ margin: 0 auto 20px;
+ color: $primary;
+ }
+}
diff --git a/static/scss/layout.scss b/static/scss/layout.scss
index 5cdb6a957..0fc8f7274 100644
--- a/static/scss/layout.scss
+++ b/static/scss/layout.scss
@@ -34,6 +34,7 @@
@import "detail/companies-trust";
@import "detail/text-section";
@import "detail/catalog-topics";
+@import "detail/course-overview";
@import "ecommerce-admin";
@import "resource";
@import "notification";
diff --git a/users/management/commands/block_users.py b/users/management/commands/block_users.py
index 8f2dacd19..bb0cf0958 100644
--- a/users/management/commands/block_users.py
+++ b/users/management/commands/block_users.py
@@ -29,7 +29,7 @@ class Command(BaseCommand):
def create_parser(self, prog_name, subcommand):
"""
- create parser to add new line in help text.
+ Create parser to add new line in help text.
"""
parser = super().create_parser(prog_name, subcommand)
parser.formatter_class = RawTextHelpFormatter
diff --git a/users/management/commands/retire_users.py b/users/management/commands/retire_users.py
index 89eaa9089..69ae742d6 100644
--- a/users/management/commands/retire_users.py
+++ b/users/management/commands/retire_users.py
@@ -48,7 +48,7 @@ class Command(BaseCommand):
def create_parser(self, prog_name, subcommand):
"""
- create parser to add new line in help text.
+ Create parser to add new line in help text.
"""
parser = super().create_parser(prog_name, subcommand)
parser.formatter_class = RawTextHelpFormatter
diff --git a/users/management/commands/unblock_users.py b/users/management/commands/unblock_users.py
index 3447926c5..0caca06b6 100644
--- a/users/management/commands/unblock_users.py
+++ b/users/management/commands/unblock_users.py
@@ -34,7 +34,7 @@ class Command(BaseCommand):
def create_parser(self, prog_name, subcommand):
"""
- create parser to add new line in help text.
+ Create parser to add new line in help text.
"""
parser = super().create_parser(prog_name, subcommand)
parser.formatter_class = RawTextHelpFormatter
diff --git a/yarn.lock b/yarn.lock
index 310546768..7e44fb52f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1344,7 +1344,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2":
+"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2":
version: 7.26.0
resolution: "@babel/runtime@npm:7.26.0"
dependencies:
@@ -1988,150 +1988,6 @@ __metadata:
languageName: node
linkType: hard
-"@parcel/watcher-android-arm64@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-android-arm64@npm:2.5.0"
- conditions: os=android & cpu=arm64
- languageName: node
- linkType: hard
-
-"@parcel/watcher-darwin-arm64@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-darwin-arm64@npm:2.5.0"
- conditions: os=darwin & cpu=arm64
- languageName: node
- linkType: hard
-
-"@parcel/watcher-darwin-x64@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-darwin-x64@npm:2.5.0"
- conditions: os=darwin & cpu=x64
- languageName: node
- linkType: hard
-
-"@parcel/watcher-freebsd-x64@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-freebsd-x64@npm:2.5.0"
- conditions: os=freebsd & cpu=x64
- languageName: node
- linkType: hard
-
-"@parcel/watcher-linux-arm-glibc@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-linux-arm-glibc@npm:2.5.0"
- conditions: os=linux & cpu=arm & libc=glibc
- languageName: node
- linkType: hard
-
-"@parcel/watcher-linux-arm-musl@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-linux-arm-musl@npm:2.5.0"
- conditions: os=linux & cpu=arm & libc=musl
- languageName: node
- linkType: hard
-
-"@parcel/watcher-linux-arm64-glibc@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.5.0"
- conditions: os=linux & cpu=arm64 & libc=glibc
- languageName: node
- linkType: hard
-
-"@parcel/watcher-linux-arm64-musl@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-linux-arm64-musl@npm:2.5.0"
- conditions: os=linux & cpu=arm64 & libc=musl
- languageName: node
- linkType: hard
-
-"@parcel/watcher-linux-x64-glibc@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-linux-x64-glibc@npm:2.5.0"
- conditions: os=linux & cpu=x64 & libc=glibc
- languageName: node
- linkType: hard
-
-"@parcel/watcher-linux-x64-musl@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-linux-x64-musl@npm:2.5.0"
- conditions: os=linux & cpu=x64 & libc=musl
- languageName: node
- linkType: hard
-
-"@parcel/watcher-win32-arm64@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-win32-arm64@npm:2.5.0"
- conditions: os=win32 & cpu=arm64
- languageName: node
- linkType: hard
-
-"@parcel/watcher-win32-ia32@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-win32-ia32@npm:2.5.0"
- conditions: os=win32 & cpu=ia32
- languageName: node
- linkType: hard
-
-"@parcel/watcher-win32-x64@npm:2.5.0":
- version: 2.5.0
- resolution: "@parcel/watcher-win32-x64@npm:2.5.0"
- conditions: os=win32 & cpu=x64
- languageName: node
- linkType: hard
-
-"@parcel/watcher@npm:^2.4.1":
- version: 2.5.0
- resolution: "@parcel/watcher@npm:2.5.0"
- dependencies:
- "@parcel/watcher-android-arm64": 2.5.0
- "@parcel/watcher-darwin-arm64": 2.5.0
- "@parcel/watcher-darwin-x64": 2.5.0
- "@parcel/watcher-freebsd-x64": 2.5.0
- "@parcel/watcher-linux-arm-glibc": 2.5.0
- "@parcel/watcher-linux-arm-musl": 2.5.0
- "@parcel/watcher-linux-arm64-glibc": 2.5.0
- "@parcel/watcher-linux-arm64-musl": 2.5.0
- "@parcel/watcher-linux-x64-glibc": 2.5.0
- "@parcel/watcher-linux-x64-musl": 2.5.0
- "@parcel/watcher-win32-arm64": 2.5.0
- "@parcel/watcher-win32-ia32": 2.5.0
- "@parcel/watcher-win32-x64": 2.5.0
- detect-libc: ^1.0.3
- is-glob: ^4.0.3
- micromatch: ^4.0.5
- node-addon-api: ^7.0.0
- node-gyp: latest
- dependenciesMeta:
- "@parcel/watcher-android-arm64":
- optional: true
- "@parcel/watcher-darwin-arm64":
- optional: true
- "@parcel/watcher-darwin-x64":
- optional: true
- "@parcel/watcher-freebsd-x64":
- optional: true
- "@parcel/watcher-linux-arm-glibc":
- optional: true
- "@parcel/watcher-linux-arm-musl":
- optional: true
- "@parcel/watcher-linux-arm64-glibc":
- optional: true
- "@parcel/watcher-linux-arm64-musl":
- optional: true
- "@parcel/watcher-linux-x64-glibc":
- optional: true
- "@parcel/watcher-linux-x64-musl":
- optional: true
- "@parcel/watcher-win32-arm64":
- optional: true
- "@parcel/watcher-win32-ia32":
- optional: true
- "@parcel/watcher-win32-x64":
- optional: true
- checksum: 253f93c5f443dfbb638df58712df077fe46ff7e01e7c78df0c4ceb001e8f5b31db01eb7ddac3ae4159722c4d1525894cd4ce5be49f5e6c14a3a52cbbf9f41cbf
- languageName: node
- linkType: hard
-
"@pkgjs/parseargs@npm:^0.11.0":
version: 0.11.0
resolution: "@pkgjs/parseargs@npm:0.11.0"
@@ -3821,7 +3677,7 @@ __metadata:
languageName: node
linkType: hard
-"chokidar@npm:^3.5.3, chokidar@npm:^3.6.0":
+"chokidar@npm:>=3.0.0 <4.0.0, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0":
version: 3.6.0
resolution: "chokidar@npm:3.6.0"
dependencies:
@@ -3840,15 +3696,6 @@ __metadata:
languageName: node
linkType: hard
-"chokidar@npm:^4.0.0":
- version: 4.0.1
- resolution: "chokidar@npm:4.0.1"
- dependencies:
- readdirp: ^4.0.1
- checksum: 193da9786b0422a895d59c7552195d15c6c636e6a2293ae43d09e34e243e24ccd02d693f007c767846a65abbeae5fea6bfacb8fc2ddec4ea4d397620d552010d
- languageName: node
- linkType: hard
-
"chownr@npm:^2.0.0":
version: 2.0.0
resolution: "chownr@npm:2.0.0"
@@ -4623,15 +4470,6 @@ __metadata:
languageName: node
linkType: hard
-"detect-libc@npm:^1.0.3":
- version: 1.0.3
- resolution: "detect-libc@npm:1.0.3"
- bin:
- detect-libc: ./bin/detect-libc.js
- checksum: daaaed925ffa7889bd91d56e9624e6c8033911bb60f3a50a74a87500680652969dbaab9526d1e200a4c94acf80fc862a22131841145a0a8482d60a99c24f4a3e
- languageName: node
- linkType: hard
-
"diff@npm:^3.1.0":
version: 3.5.0
resolution: "diff@npm:3.5.0"
@@ -6700,7 +6538,7 @@ __metadata:
languageName: node
linkType: hard
-"history@npm:^4.6.3, history@npm:^4.7.2":
+"history@npm:^4.7.2":
version: 4.10.1
resolution: "history@npm:4.10.1"
dependencies:
@@ -6714,6 +6552,15 @@ __metadata:
languageName: node
linkType: hard
+"history@npm:^5.0.0":
+ version: 5.3.0
+ resolution: "history@npm:5.3.0"
+ dependencies:
+ "@babel/runtime": ^7.7.6
+ checksum: d73c35df49d19ac172f9547d30a21a26793e83f16a78386d99583b5bf1429cc980799fcf1827eb215d31816a6600684fba9686ce78104e23bd89ec239e7c726f
+ languageName: node
+ linkType: hard
+
"hls.js@npm:^0.14.0":
version: 0.14.17
resolution: "hls.js@npm:0.14.17"
@@ -6972,10 +6819,10 @@ __metadata:
languageName: node
linkType: hard
-"immutable@npm:^5.0.2":
- version: 5.0.2
- resolution: "immutable@npm:5.0.2"
- checksum: 4adbd70580c78fafa7e367473e4f307f78f6768372ef464d45ba55f670b7f61d92c71461da571d80011544b61416d3dee0132736d9b592f33dcfa86e43bdd8f1
+"immutable@npm:^4.0.0":
+ version: 4.3.7
+ resolution: "immutable@npm:4.3.7"
+ checksum: 1c50eb053bb300796551604afff554066f041aa8e15926cf98f6d11d9736b62ad12531c06515dd96375258653878b4736f8051cd20b640f5f976d09fa640e3ec
languageName: node
linkType: hard
@@ -7316,7 +7163,7 @@ __metadata:
languageName: node
linkType: hard
-"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1":
+"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:~4.0.1":
version: 4.0.3
resolution: "is-glob@npm:4.0.3"
dependencies:
@@ -8860,7 +8707,7 @@ __metadata:
flow-bin: ^0.95.1
flow-typed: ^3.2.1
formik: ^2.0.0
- history: ^4.6.3
+ history: ^5.0.0
hls.js: ^0.14.0
isomorphic-fetch: ^2.2.1
jquery: ^3.5.1
@@ -8905,7 +8752,7 @@ __metadata:
redux-logger: ^3.0.6
redux-query: ^2.3.1
reselect: ^4.0.0
- sass: ~1.81.0
+ sass: ~1.64.2
sass-lint: ^1.13.1
sass-loader: ^12.1.0
serialize-javascript: ^3.1.0
@@ -9104,15 +8951,6 @@ __metadata:
languageName: node
linkType: hard
-"node-addon-api@npm:^7.0.0":
- version: 7.1.1
- resolution: "node-addon-api@npm:7.1.1"
- dependencies:
- node-gyp: latest
- checksum: 46051999e3289f205799dfaf6bcb017055d7569090f0004811110312e2db94cb4f8654602c7eb77a60a1a05142cc2b96e1b5c56ca4622c41a5c6370787faaf30
- languageName: node
- linkType: hard
-
"node-fetch@npm:^1.0.1, node-fetch@npm:^1.3.3":
version: 1.7.3
resolution: "node-fetch@npm:1.7.3"
@@ -10650,13 +10488,6 @@ __metadata:
languageName: node
linkType: hard
-"readdirp@npm:^4.0.1":
- version: 4.0.2
- resolution: "readdirp@npm:4.0.2"
- checksum: 309376e717f94fb7eb61bec21e2603243a9e2420cd2e9bf94ddf026aefea0d7377ed1a62f016d33265682e44908049a55c3cfc2307450a1421654ea008489b39
- languageName: node
- linkType: hard
-
"readdirp@npm:~3.6.0":
version: 3.6.0
resolution: "readdirp@npm:3.6.0"
@@ -11275,20 +11106,16 @@ __metadata:
languageName: node
linkType: hard
-"sass@npm:~1.81.0":
- version: 1.81.0
- resolution: "sass@npm:1.81.0"
+"sass@npm:~1.64.2":
+ version: 1.64.2
+ resolution: "sass@npm:1.64.2"
dependencies:
- "@parcel/watcher": ^2.4.1
- chokidar: ^4.0.0
- immutable: ^5.0.2
+ chokidar: ">=3.0.0 <4.0.0"
+ immutable: ^4.0.0
source-map-js: ">=0.6.2 <2.0.0"
- dependenciesMeta:
- "@parcel/watcher":
- optional: true
bin:
sass: sass.js
- checksum: 93db5b342c3b0449af2b08123ed4c0793643bd3a30f78e4e0686a1aa991ad640e0d9bc8da09aa5d7ff313bbd317b3be9c827cca60fb33b07d9f4b14b001eccfe
+ checksum: 43a5c9b9b3b6ba27feb5c45eba90edc437b15a30fd443f5d2623bbd59fe4a922f2a6a9990296c6a6c2b5bce7f401922c5049357415f50b745952c2d478bc5526
languageName: node
linkType: hard