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/app.json b/app.json index 1e79a70ec..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": { 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..b932385d1 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 @@ -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/sync_external_courses/emeritus_api.py b/courses/sync_external_courses/external_course_sync_api.py similarity index 58% rename from courses/sync_external_courses/emeritus_api.py rename to courses/sync_external_courses/external_course_sync_api.py index 8d0dee015..4d58789e9 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 @@ -23,40 +23,80 @@ ) 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 EmeritusJobStatus(Enum): +class GlobalAlumniKeyMap(ExternalCourseVendorBaseKeyMap): """ - Status of an Emeritus Job. + 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 ExternalCourseSyncAPIJobStatus(Enum): + """ + Status of an External Course API Job. """ READY = 3 @@ -64,79 +104,83 @@ 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 "" - # 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 +204,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 +239,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 +268,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 +300,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 +390,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: @@ -424,43 +473,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 +529,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( + 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 +548,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 +565,12 @@ 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, + format=external_course.format, + description=external_course.description, background_image=image, thumbnail_image=image, ) @@ -530,16 +581,16 @@ 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 emeritus_course.duration: - latest_revision.duration = emeritus_course.duration + if not latest_revision.duration and external_course.duration: + latest_revision.duration = external_course.duration is_updated = True - if not latest_revision.description and emeritus_course.description: - latest_revision.description = emeritus_course.description + 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 +607,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 +687,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 +707,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 +733,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 +743,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 +753,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`. 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..b3cd7bd25 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,25 @@ 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.description == external_course_data["description"] assert course_page_created == (not create_course_page) assert course_page_updated == create_course_page @@ -231,22 +269,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 +299,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 +310,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 +327,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 +344,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 +363,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 +391,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 +413,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 +423,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 +437,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 +459,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 +473,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 +552,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 +581,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 +640,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 +723,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 +751,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 +762,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 +785,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 +820,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 +829,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 +843,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 +914,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 +938,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 a0aae1d17..14dda4916 100644 --- a/courses/urls.py +++ b/courses/urls.py @@ -4,7 +4,7 @@ from rest_framework import routers from courses.views import v1 -from courses.views.v1 import EmeritusCourseListView +from courses.views.v1 import ExternalCourseListView router = routers.SimpleRouter() router.register(r"programs", v1.ProgramViewSet, basename="programs_api") @@ -31,8 +31,8 @@ r"^api/enrollments/", v1.UserEnrollmentsView.as_view(), name="user-enrollments" ), path( - "api/emeritus_courses/", - EmeritusCourseListView.as_view(), - name="emeritus_courses", + "api/external_courses//", + ExternalCourseListView.as_view(), + name="external_courses", ), ] diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py index 951d197c2..86177ae15 100644 --- a/courses/views/v1/__init__.py +++ b/courses/views/v1/__init__.py @@ -27,7 +27,10 @@ ProgramEnrollmentSerializer, ProgramSerializer, ) -from courses.sync_external_courses.emeritus_api import fetch_emeritus_courses +from courses.sync_external_courses.external_course_sync_api import ( + EXTERNAL_COURSE_VENDOR_KEYMAPS, + fetch_external_courses, +) from ecommerce.models import Product @@ -205,19 +208,29 @@ def get_queryset(self): return CourseTopic.parent_topics_with_courses() -class EmeritusCourseListView(APIView): +class ExternalCourseListView(APIView): """ - ReadOnly View to list Emeritus courses. + ReadOnly View to list External courses. """ permission_classes = [IsAdminUser] def get(self, request, *args, **kwargs): # noqa: ARG002 """ - Get Emeritus courses list from the Emeritus API and return it. + 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_emeritus_courses() + data = fetch_external_courses(keymap()) return Response(data, status=status.HTTP_200_OK) except Exception as e: # noqa: BLE001 return Response( diff --git a/courses/views_test.py b/courses/views_test.py index b252fbc01..99b6e6864 100644 --- a/courses/views_test.py +++ b/courses/views_test.py @@ -37,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 @@ -616,9 +620,14 @@ def test_course_topics_api(client, django_assert_num_queries): @pytest.mark.parametrize("expected_status_code", [200, 500]) -def test_emeritus_course_list_view(admin_drf_client, mocker, expected_status_code): +@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 Emeritus API List calls fetch_emeritus_courses and returns its mocked response. + Test that the External API List calls fetch_external_courses and returns its mocked response. """ if expected_status_code == 200: with Path( @@ -626,12 +635,12 @@ def test_emeritus_course_list_view(admin_drf_client, mocker, expected_status_cod ).open() as test_data_file: mocked_response = json.load(test_data_file)["rows"] - patched_fetch_emeritus_courses = mocker.patch( - "courses.views.v1.fetch_emeritus_courses", return_value=mocked_response + patched_fetch_external_courses = mocker.patch( + "courses.views.v1.fetch_external_courses", return_value=mocked_response ) else: - patched_fetch_emeritus_courses = mocker.patch( - "courses.views.v1.fetch_emeritus_courses", + patched_fetch_external_courses = mocker.patch( + "courses.views.v1.fetch_external_courses", side_effect=Exception("Some error occurred."), ) mocked_response = { @@ -639,7 +648,9 @@ def test_emeritus_course_list_view(admin_drf_client, mocker, expected_status_cod "details": "Some error occurred.", } - response = admin_drf_client.get(reverse("emeritus_courses")) + 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_emeritus_courses.assert_called_once() + patched_fetch_external_courses.assert_called_once() diff --git a/mitxpro/settings.py b/mitxpro/settings.py index 4816f8f5d..37f164cb3 100644 --- a/mitxpro/settings.py +++ b/mitxpro/settings.py @@ -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, @@ -1139,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