diff --git a/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py index 4aa53480f1..c14734d32c 100644 --- a/learning_resources/etl/loaders_test.py +++ b/learning_resources/etl/loaders_test.py @@ -554,6 +554,35 @@ def test_load_course( # noqa: PLR0913, PLR0912, PLR0915 assert getattr(result, key) == value, f"Property {key} should equal {value}" +def test_load_course_content_tags(mock_upsert_tasks): + """Test that content_tags in course data are persisted as resource_tags""" + platform = LearningResourcePlatformFactory.create(code=PlatformType.mitxonline.name) + now = now_in_utc() + props = { + "readable_id": "program-v1:MITx+test", + "platform": platform.code, + "etl_source": ETLSource.mitxonline.name, + "resource_type": LearningResourceType.course.name, + "title": "Test Program as Course", + "image": {"url": "https://www.test.edu/image.jpg"}, + "description": "description", + "url": "https://test.edu", + "published": True, + "content_tags": ["Program as Course"], + "runs": [ + { + "run_id": "program-v1:MITx+test", + "start_date": now, + "end_date": now + timedelta(30), + } + ], + } + result = load_course(props, [], [], config=CourseLoaderConfig(prune=True)) + assert list(result.resource_tags.values_list("name", flat=True)) == [ + "Program as Course" + ] + + def test_load_course_bad_platform(mocker): """A bad platform should log an exception and not create the course""" mock_log = mocker.patch("learning_resources.etl.loaders.log.exception") diff --git a/learning_resources/etl/mitxonline.py b/learning_resources/etl/mitxonline.py index 4ebfe0b52e..e03af7c7cb 100644 --- a/learning_resources/etl/mitxonline.py +++ b/learning_resources/etl/mitxonline.py @@ -30,6 +30,7 @@ transform_price, transform_topics, ) +from main import features from main.utils import clean_data, now_in_utc log = logging.getLogger(__name__) @@ -354,6 +355,113 @@ def _transform_course(course): } +def transform_program_as_course(program: dict) -> dict: + """ + Transform a MITx Online program into a course-shaped dict. + + Used for programs with display_mode="course" that should be ingested + as LearningResource objects with resource_type=course. + + Args: + program (dict): program data from MITx Online API + + Returns: + dict: normalized course data + """ + courses = transform_courses( + [ + course + for course in _fetch_courses_by_ids(program.get("courses", [])) + if not re.search(EXCLUDE_REGEX, course["title"], re.IGNORECASE) + ] + ) + pace = sorted({course_pace for course in courses for course_pace in course["pace"]}) + run = { + "run_id": program["readable_id"], + "enrollment_start": _parse_datetime(program.get("enrollment_start")), + "enrollment_end": _parse_datetime(program.get("enrollment_end")), + "start_date": _parse_datetime( + program.get("start_date") or program.get("enrollment_start") + ), + "end_date": _parse_datetime(program.get("end_date")), + "title": program["title"], + "published": bool(parse_page_attribute(program, "page_url")), + "url": parse_page_attribute(program, "page_url", is_url=True), + "image": _transform_image(program), + "description": clean_data(parse_page_attribute(program, "description")), + "prices": parse_prices( + program, + program.get("enrollment_modes", []), + fully_enrollable=True, + ), + "status": RunStatus.current.value + if parse_page_attribute(program, "page_url") + else RunStatus.archived.value, + "enrollment_modes": program.get("enrollment_modes", []), + "availability": program.get("availability"), + "format": [Format.asynchronous.name], + "pace": pace, + "duration": program.get("duration") or "", + "min_weeks": program.get("min_weeks"), + "max_weeks": program.get("max_weeks"), + "time_commitment": program.get("time_commitment") or "", + "min_weekly_hours": parse_string_to_int(program.get("min_weekly_hours")), + "max_weekly_hours": parse_string_to_int(program.get("max_weekly_hours")), + } + runs = [run] + has_certification = parse_certification(OFFERED_BY["code"], runs) + strip_enrollment_modes(runs) + return { + "readable_id": program["readable_id"], + "platform": PlatformType.mitxonline.name, + "etl_source": ETLSource.mitxonline.name, + "resource_type": LearningResourceType.course.name, + "title": program["title"], + "offered_by": OFFERED_BY, + "topics": transform_topics(program.get("topics", []), OFFERED_BY["code"]), + "departments": parse_departments(program.get("departments", [])), + "runs": runs, + "force_ingest": False, + "content_tags": ["Program as Course"], + "course": { + "course_numbers": generate_course_numbers_json( + program["readable_id"], is_ocw=False + ), + }, + "published": bool( + parse_page_attribute(program, "page_url") + and parse_page_attribute(program, "live") + ), + "professional": False, + "certification": has_certification, + "certification_type": parse_certificate_type( + program.get("certificate_type", CertificationType.none.name) + ) + if has_certification + else CertificationType.none.name, + "image": _transform_image(program), + "url": parse_page_attribute(program, "page_url", is_url=True), + "description": clean_data(parse_page_attribute(program, "description")), + "availability": program.get("availability"), + "format": [Format.asynchronous.name], + "pace": pace, + } + + +def transform_programs_as_courses(programs: list[dict]) -> list[dict]: + """ + Transform a list of MITx Online programs into course-shaped dicts. + + Args: + programs (list of dict): programs data (already filtered to those + that should be ingested as courses) + + Returns: + list of dict: normalized course data + """ + return [transform_program_as_course(program) for program in programs] + + def transform_courses(courses): """ Transforms a list of courses into our normalized data structure @@ -469,9 +577,27 @@ def transform_programs(programs: list[dict]) -> list[dict]: "published": bool( parse_page_attribute(program, "page_url") and parse_page_attribute(program, "live") - ), # a program is only considered published if it has a page url + ), "format": [Format.asynchronous.name], "pace": pace, "runs": [run], "courses": courses, } + + +def is_program_course(program: dict) -> bool: + """ + Determine if a MITx Online program should be ingested as a course. + + Requires the "program-to-course" feature flag to be enabled. When enabled, + programs with display_mode="course" are ingested as courses. + + Args: + program (dict): program data from MITx Online API + + Returns: + bool: True if the program should be ingested as a course, False otherwise + """ + return program.get("display_mode") == "course" and features.is_enabled( + "program-to-course", default=False + ) diff --git a/learning_resources/etl/mitxonline_test.py b/learning_resources/etl/mitxonline_test.py index 116a15cf03..1ae987e706 100644 --- a/learning_resources/etl/mitxonline_test.py +++ b/learning_resources/etl/mitxonline_test.py @@ -28,11 +28,14 @@ extract_courses, extract_programs, is_fully_enrollable, + is_program_course, parse_certificate_type, parse_page_attribute, parse_prices, transform_courses, + transform_program_as_course, transform_programs, + transform_programs_as_courses, transform_topics, ) from learning_resources.etl.utils import ( @@ -42,7 +45,7 @@ strip_enrollment_modes, ) from learning_resources.test_utils import set_up_topics -from main.test_utils import any_instance_of +from main.test_utils import any_instance_of, assert_json_equal from main.utils import clean_data pytestmark = pytest.mark.django_db @@ -131,9 +134,15 @@ def test_mitxonline_transform_programs( return_value=mock_mitxonline_courses_data["results"], ) - result = transform_programs(mock_mitxonline_programs_data["results"]) + # Filter out display_mode="course" programs (these are handled separately) + regular_programs = [ + p + for p in mock_mitxonline_programs_data["results"] + if p.get("display_mode") != "course" + ] + result = transform_programs(regular_programs) expected = [] - for program_data in mock_mitxonline_programs_data["results"]: + for program_data in regular_programs: expected_courses = [] for course_data in sorted( mock_mitxonline_courses_data["results"], @@ -450,12 +459,16 @@ def test_program_run_start_date_value( # noqa: PLR0913 ) """Test that the start date value is correctly determined for program runs""" - mock_mitxonline_programs_data["results"][0]["start_date"] = start_dt - mock_mitxonline_programs_data["results"][0]["enrollment_start"] = enrollment_dt + # Use only regular programs (not display_mode="course") + regular_programs = [ + p + for p in mock_mitxonline_programs_data["results"] + if p.get("display_mode") != "course" + ] + regular_programs[0]["start_date"] = start_dt + regular_programs[0]["enrollment_start"] = enrollment_dt - transformed_programs = list( - transform_programs(mock_mitxonline_programs_data["results"]) - ) + transformed_programs = list(transform_programs(regular_programs)) assert transformed_programs[0]["runs"][0]["start_date"] == _parse_datetime( expected_dt @@ -861,3 +874,222 @@ def test_transform_program_certification_by_enrollment_modes( assert result["certification"] is expected["certification"] assert result["certification_type"] == expected["certification_type"] + + +def test_mitxonline_transform_programs_as_courses( + mock_mitxonline_programs_data, mock_mitxonline_courses_data, mocker, settings +): + """Test that programs with display_mode='course' are transformed into course-shaped dicts""" + set_up_topics(is_mitx=True) + + mock_now = datetime(2023, 1, 1, tzinfo=UTC) + mocker.patch("learning_resources.etl.mitxonline.now_in_utc", return_value=mock_now) + + settings.MITX_ONLINE_COURSES_API_URL = "http://localhost/test/courses/api" + mocker.patch( + "learning_resources.etl.mitxonline._fetch_data", + return_value=mock_mitxonline_courses_data["results"], + ) + + course_programs = [ + p + for p in mock_mitxonline_programs_data["results"] + if p.get("display_mode") == "course" + ] + assert len(course_programs) > 0, ( + "Fixture should have at least one display_mode=course program" + ) + + result = transform_programs_as_courses(course_programs) + expected = [transform_program_as_course(p) for p in course_programs] + assert_json_equal(result, expected) + + # Verify the key properties that distinguish these from regular programs + for transformed in result: + assert transformed["resource_type"] == LearningResourceType.course.name + assert transformed["content_tags"] == ["Program as Course"] + assert "course" in transformed + assert "courses" not in transformed + + +def test_transform_program_as_course( + mock_mitxonline_programs_data, mock_mitxonline_courses_data, mocker, settings +): + """Test that a single program with display_mode='course' is transformed correctly""" + set_up_topics(is_mitx=True) + + mock_now = datetime(2023, 1, 1, tzinfo=UTC) + mocker.patch("learning_resources.etl.mitxonline.now_in_utc", return_value=mock_now) + + settings.MITX_ONLINE_BASE_URL = "https://mitxonline.mit.edu" + settings.MITX_ONLINE_COURSES_API_URL = "http://localhost/test/courses/api" + mocker.patch( + "learning_resources.etl.mitxonline._fetch_data", + return_value=mock_mitxonline_courses_data["results"], + ) + + program = next( + p + for p in mock_mitxonline_programs_data["results"] + if p.get("display_mode") == "course" + ) + + result = transform_program_as_course(program) + + base_url = settings.MITX_ONLINE_BASE_URL + expected = { + "readable_id": "program-v1:MITxT+UAI.DS", + "platform": PlatformType.mitxonline.name, + "etl_source": ETLSource.mitxonline.name, + "resource_type": LearningResourceType.course.name, + "title": "UAI Applied Data Science", + "offered_by": OFFERED_BY, + "topics": [{"name": "Mathematics"}], + "departments": ["18"], + "runs": [ + { + "run_id": "program-v1:MITxT+UAI.DS", + "enrollment_start": _parse_datetime("2024-01-01T00:00:00Z"), + "enrollment_end": _parse_datetime("2024-04-01T00:00:00Z"), + "start_date": _parse_datetime("2024-01-15T00:00:00Z"), + "end_date": _parse_datetime("2024-04-15T00:00:00Z"), + "title": "UAI Applied Data Science", + "published": True, + "url": f"{base_url}/programs/program-v1:MITxT+UAI.DS/", + "image": {"url": f"{base_url}/static/images/uai-data-science.png"}, + "description": "
Applied Data Science program displayed as a course.
", + "prices": [ + {"amount": 0.0, "currency": CURRENCY_USD}, + {"amount": 100.0, "currency": CURRENCY_USD}, + {"amount": 200.0, "currency": CURRENCY_USD}, + ], + "status": RunStatus.current.value, + "availability": "anytime", + "format": [Format.asynchronous.name], + "pace": [Pace.instructor_paced.name], + "duration": "10-12 weeks", + "min_weeks": 10, + "max_weeks": 12, + "time_commitment": "5-7 hrs/wk", + "min_weekly_hours": 5, + "max_weekly_hours": 7, + } + ], + "force_ingest": False, + "content_tags": ["Program as Course"], + "course": { + "course_numbers": [ + { + "value": "program-v1:MITxT+UAI.DS", + "listing_type": "primary", + "department": None, + "sort_coursenum": "program-v1:MITxT+UAI.DS", + "primary": True, + } + ] + }, + "published": True, + "professional": False, + "certification": True, + "certification_type": CertificationType.completion.name, + "image": {"url": f"{base_url}/static/images/uai-data-science.png"}, + "url": f"{base_url}/programs/program-v1:MITxT+UAI.DS/", + "description": "Applied Data Science program displayed as a course.
", + "availability": "anytime", + "format": [Format.asynchronous.name], + "pace": [Pace.instructor_paced.name], + } + assert_json_equal(result, expected) + + +@pytest.mark.parametrize( + ("enrollment_modes", "expected"), + [ + ( + [{"mode_slug": "verified"}, {"mode_slug": "audit"}], + { + "certification": True, + "certification_type": CertificationType.completion.name, + }, + ), + ( + [{"mode_slug": "verified"}], + { + "certification": True, + "certification_type": CertificationType.completion.name, + }, + ), + ( + [{"mode_slug": "audit"}], + {"certification": False, "certification_type": CertificationType.none.name}, + ), + ( + [], + {"certification": False, "certification_type": CertificationType.none.name}, + ), + ], +) +def test_transform_program_as_course_certification_by_enrollment_modes( # noqa: PLR0913 + mocker, + mock_mitxonline_programs_data, + mock_mitxonline_courses_data, + settings, + enrollment_modes, + expected, +): + """Test that program-as-course certification depends on + whether enrollment_modes includes 'verified'. + """ + set_up_topics(is_mitx=True) + + mock_now = datetime(2023, 1, 1, tzinfo=UTC) + mocker.patch("learning_resources.etl.mitxonline.now_in_utc", return_value=mock_now) + + settings.MITX_ONLINE_COURSES_API_URL = "http://localhost/test/courses/api" + mocker.patch( + "learning_resources.etl.mitxonline._fetch_data", + return_value=mock_mitxonline_courses_data["results"], + ) + + program = next( + p + for p in mock_mitxonline_programs_data["results"] + if p.get("display_mode") == "course" + ) + program = {**program, "enrollment_modes": enrollment_modes} + program["page"] = {**program["page"]} + program["page"]["page_url"] = "/programs/test/" + program["page"]["live"] = True + program["certificate_type"] = "Certificate of Completion" + + result = transform_program_as_course(program) + + assert result["certification"] is expected["certification"] + assert result["certification_type"] == expected["certification_type"] + + +def test_mitxonline_transform_programs_as_courses_empty(): + """Test that transform_programs_as_courses returns empty list for empty input""" + assert transform_programs_as_courses([]) == [] + + +@pytest.mark.parametrize( + ("flag_enabled", "display_mode", "expected"), + [ + (True, "course", True), + (True, "program", False), + (True, None, False), + (False, "course", False), + (False, "program", False), + ], +) +def test_is_program_course(mocker, flag_enabled, display_mode, expected): + """Test that is_program_course checks the feature flag and display_mode""" + mocker.patch( + "learning_resources.etl.mitxonline.features.is_enabled", + return_value=flag_enabled, + ) + program = {"readable_id": "program-v1:test"} + if display_mode is not None: + program["display_mode"] = display_mode + assert is_program_course(program) is expected diff --git a/learning_resources/etl/pipelines.py b/learning_resources/etl/pipelines.py index 09814281e7..a4ab0b02a1 100644 --- a/learning_resources/etl/pipelines.py +++ b/learning_resources/etl/pipelines.py @@ -66,21 +66,42 @@ mit_edx_programs.extract, ) -mitxonline_programs_etl = compose( - load_programs( + +def mitxonline_etl() -> tuple[list[LearningResource], list[LearningResource]]: + """ + ETL for MITx Online courses and programs. + + Programs with display_mode='course' are ingested as courses. + All other programs are ingested normally. + """ + all_programs = list(mitxonline.extract_programs()) + + # Partition programs: display_mode="course" → load as courses, rest → as programs + regular_programs = [p for p in all_programs if not mitxonline.is_program_course(p)] + course_programs = [p for p in all_programs if mitxonline.is_program_course(p)] + + # Courses: regular courses + programs-as-courses combined for single prune pass + courses_data = list(mitxonline.transform_courses(mitxonline.extract_courses())) + programs_as_courses = mitxonline.transform_programs_as_courses(course_programs) + all_courses_data = courses_data + programs_as_courses + + courses = loaders.load_courses( + ETLSource.mitxonline.name, + all_courses_data, + config=CourseLoaderConfig(prune=True), + ) + + # Programs: only regular programs + programs = loaders.load_programs( ETLSource.mitxonline.name, + list(mitxonline.transform_programs(regular_programs)), config=ProgramLoaderConfig( courses=CourseLoaderConfig(fetch_only=True), prune=True ), - ), - mitxonline.transform_programs, - mitxonline.extract_programs, -) -mitxonline_courses_etl = compose( - load_courses(ETLSource.mitxonline.name, config=CourseLoaderConfig(prune=True)), - mitxonline.transform_courses, - mitxonline.extract_courses, -) + ) + + return courses, programs + oll_etl = compose( load_courses(ETLSource.oll.name, config=CourseLoaderConfig(prune=True)), diff --git a/learning_resources/etl/pipelines_test.py b/learning_resources/etl/pipelines_test.py index 7c7dc708d6..d21360a31a 100644 --- a/learning_resources/etl/pipelines_test.py +++ b/learning_resources/etl/pipelines_test.py @@ -87,56 +87,72 @@ def test_mit_edx_programs_etl(): assert result == mock_load_programs.return_value -def test_mitxonline_programs_etl(): - """Verify that mitxonline programs etl pipeline executes correctly""" - with reload_mocked_pipeline( - patch("learning_resources.etl.mitxonline.extract_programs", autospec=True), - patch("learning_resources.etl.mitxonline.transform_programs", autospec=False), - patch("learning_resources.etl.loaders.load_programs", autospec=True), - ) as patches: - mock_extract, mock_transform, mock_load_programs = patches - result = pipelines.mitxonline_programs_etl() - - mock_extract.assert_called_once_with() - - # each of these should be called with the return value of the extract - mock_transform.assert_called_once_with(mock_extract.return_value) - - # load_courses should be called *only* with the return value of transform - mock_load_programs.assert_called_once_with( - ETLSource.mitxonline.name, - mock_transform.return_value, - config=ProgramLoaderConfig( - courses=CourseLoaderConfig(fetch_only=True), prune=True - ), +def test_mitxonline_etl(mocker): + """Verify that the combined mitxonline etl pipeline executes correctly""" + mocker.patch( + "learning_resources.etl.mitxonline.features.is_enabled", + return_value=True, + ) + regular_program = {"readable_id": "prog-1", "title": "Regular Program"} + course_program = { + "readable_id": "prog-2", + "title": "Course Program", + "display_mode": "course", + } + mock_extract_programs = mocker.patch( + "learning_resources.etl.mitxonline.extract_programs", + return_value=[regular_program, course_program], + ) + mock_extract_courses = mocker.patch( + "learning_resources.etl.mitxonline.extract_courses", + return_value=[{"id": 1}], + ) + mock_transform_courses = mocker.patch( + "learning_resources.etl.mitxonline.transform_courses", + return_value=[{"readable_id": "course-1"}], + ) + mock_transform_programs_as_courses = mocker.patch( + "learning_resources.etl.mitxonline.transform_programs_as_courses", + return_value=[{"readable_id": "prog-2-as-course"}], ) + mock_transform_programs = mocker.patch( + "learning_resources.etl.mitxonline.transform_programs", + return_value=[{"readable_id": "prog-1-transformed"}], + ) + mock_load_courses = mocker.patch("learning_resources.etl.loaders.load_courses") + mock_load_programs = mocker.patch("learning_resources.etl.loaders.load_programs") - assert result == mock_load_programs.return_value + results = pipelines.mitxonline_etl() + mock_extract_programs.assert_called_once_with() + mock_extract_courses.assert_called_once_with() -def test_mitxonline_courses_etl(): - """Verify that mitxonline courses etl pipeline executes correctly""" - with reload_mocked_pipeline( - patch("learning_resources.etl.mitxonline.extract_courses", autospec=True), - patch("learning_resources.etl.mitxonline.transform_courses", autospec=False), - patch("learning_resources.etl.loaders.load_courses", autospec=True), - ) as patches: - mock_extract, mock_transform, mock_load_courses = patches - result = pipelines.mitxonline_courses_etl() + # transform_courses should be called with extracted courses + mock_transform_courses.assert_called_once_with(mock_extract_courses.return_value) - mock_extract.assert_called_once_with() + # transform_programs_as_courses should get only display_mode="course" programs + mock_transform_programs_as_courses.assert_called_once_with([course_program]) - # each of these should be called with the return value of the extract - mock_transform.assert_called_once_with(mock_extract.return_value) + # transform_programs should get only regular programs + mock_transform_programs.assert_called_once_with([regular_program]) - # load_courses should be called *only* with the return value of transform + # load_courses should get combined regular courses + programs-as-courses mock_load_courses.assert_called_once_with( ETLSource.mitxonline.name, - mock_transform.return_value, + [{"readable_id": "course-1"}, {"readable_id": "prog-2-as-course"}], config=CourseLoaderConfig(prune=True), ) - assert result == mock_load_courses.return_value + # load_programs should get only regular program transforms + mock_load_programs.assert_called_once_with( + ETLSource.mitxonline.name, + list(mock_transform_programs.return_value), + config=ProgramLoaderConfig( + courses=CourseLoaderConfig(fetch_only=True), prune=True + ), + ) + + assert results == (mock_load_courses.return_value, mock_load_programs.return_value) def test_oll_etl(): diff --git a/learning_resources/tasks.py b/learning_resources/tasks.py index 975efa8a97..74e2e5b7bf 100644 --- a/learning_resources/tasks.py +++ b/learning_resources/tasks.py @@ -122,8 +122,7 @@ def get_mit_edx_data( @app.task def get_mitxonline_data() -> int: """Execute the MITX Online ETL pipeline""" - courses = pipelines.mitxonline_courses_etl() - programs = pipelines.mitxonline_programs_etl() + courses, programs = pipelines.mitxonline_etl() clear_views_cache() return len(courses) + len(programs) diff --git a/learning_resources/tasks_test.py b/learning_resources/tasks_test.py index 50b6486879..0a82921d33 100644 --- a/learning_resources/tasks_test.py +++ b/learning_resources/tasks_test.py @@ -63,7 +63,8 @@ def test_cache_is_cleared_after_task_run(mocker, mocked_celery): """Test that the search cache is cleared out after every task run""" mocker.patch("learning_resources.tasks.ocw_courses_etl", autospec=True) mocker.patch("learning_resources.tasks.get_content_tasks", autospec=True) - mocker.patch("learning_resources.tasks.pipelines") + mock_pipelines = mocker.patch("learning_resources.tasks.pipelines") + mock_pipelines.mitxonline_etl.return_value = ([], []) mocked_clear_views_cache = mocker.patch( "learning_resources.tasks.clear_views_cache" ) @@ -107,9 +108,9 @@ def test_get_mit_edx_data_valid(mocker): def test_get_mitxonline_data(mocker): """Verify that the get_mitxonline_data invokes the MITx Online ETL pipeline""" mock_pipelines = mocker.patch("learning_resources.tasks.pipelines") + mock_pipelines.mitxonline_etl.return_value = ([], []) tasks.get_mitxonline_data.delay() - mock_pipelines.mitxonline_programs_etl.assert_called_once_with() - mock_pipelines.mitxonline_courses_etl.assert_called_once_with() + mock_pipelines.mitxonline_etl.assert_called_once_with() def test_get_oll_data(mocker): diff --git a/main/settings_course_etl.py b/main/settings_course_etl.py index 24157fcbb0..1fa7d04b1d 100644 --- a/main/settings_course_etl.py +++ b/main/settings_course_etl.py @@ -57,7 +57,6 @@ MITX_ONLINE_PROGRAMS_API_URL = get_string("MITX_ONLINE_PROGRAMS_API_URL", None) MITX_ONLINE_COURSES_API_URL = get_string("MITX_ONLINE_COURSES_API_URL", None) - # Open Learning Library settings OLL_COURSE_BUCKET_PREFIX = get_string( "OLL_COURSE_BUCKET_PREFIX", "open-learning-library/courses" diff --git a/test_json/mitxonline_programs.json b/test_json/mitxonline_programs.json index b24d7aef60..31d0d03f87 100644 --- a/test_json/mitxonline_programs.json +++ b/test_json/mitxonline_programs.json @@ -1,5 +1,5 @@ { - "count": 9, + "count": 10, "next": null, "previous": null, "results": [ @@ -118,6 +118,58 @@ { "mode_slug": "verified" }, { "mode_slug": "audit" } ] + }, + { + "title": "UAI Applied Data Science", + "readable_id": "program-v1:MITxT+UAI.DS", + "id": 99, + "courses": [62, 74], + "requirements": { + "required": [62, 74], + "electives": [] + }, + "req_tree": [], + "page": { + "feature_image_src": "/static/images/uai-data-science.png", + "page_url": "/programs/program-v1:MITxT+UAI.DS/", + "financial_assistance_form_url": "", + "description": "\u003Cp\u003EApplied Data Science program displayed as a course.\u003C/p\u003E", + "live": true, + "length": "10 weeks", + "effort": "5 hrs/wk", + "price": "$100" + }, + "display_mode": "course", + "min_price": 100, + "max_price": 200, + "program_type": "Series", + "certificate_type": "Certificate of Completion", + "departments": [ + { + "name": "Mathematics" + } + ], + "live": true, + "topics": [ + { + "name": "Mathematics" + } + ], + "availability": "anytime", + "start_date": "2024-01-15T00:00:00Z", + "end_date": "2024-04-15T00:00:00Z", + "enrollment_start": "2024-01-01T00:00:00Z", + "enrollment_end": "2024-04-01T00:00:00Z", + "duration": "10-12 weeks", + "min_weeks": 10, + "max_weeks": 12, + "time_commitment": "5-7 hrs/wk", + "min_weekly_hours": "5", + "max_weekly_hours": "7", + "enrollment_modes": [ + { "mode_slug": "verified" }, + { "mode_slug": "audit" } + ] } ] }