From f74ffec015a0b2462b74205116d8b554bf976660 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Tue, 24 Sep 2024 08:31:34 -0400 Subject: [PATCH] Pace and format fields for learning resources --- .pre-commit-config.yaml | 2 +- frontends/api/src/generated/v1/api.ts | 152 +++++++++++ .../test-utils/factories/learningResources.ts | 18 ++ learning_resources/constants.py | 14 + learning_resources/etl/constants.py | 2 +- learning_resources/etl/micromasters.py | 78 ++++-- learning_resources/etl/micromasters_test.py | 9 + learning_resources/etl/mitxonline.py | 52 +++- learning_resources/etl/mitxonline_test.py | 18 +- learning_resources/etl/ocw.py | 6 + learning_resources/etl/ocw_test.py | 6 + learning_resources/etl/oll.py | 6 + learning_resources/etl/oll_test.py | 8 + learning_resources/etl/openedx.py | 50 +++- learning_resources/etl/openedx_test.py | 16 ++ learning_resources/etl/prolearn.py | 10 +- learning_resources/etl/prolearn_test.py | 12 + learning_resources/etl/sloan.py | 54 +++- learning_resources/etl/sloan_test.py | 57 ++++ learning_resources/etl/xpro.py | 10 + learning_resources/etl/xpro_test.py | 14 + .../0068_learningresource_format_pace.py | 75 +++++ learning_resources/models.py | 40 +++ learning_resources/serializers.py | 36 +++ learning_resources/serializers_test.py | 9 + learning_resources_search/constants.py | 28 ++ main/models.py | 2 +- openapi/specs/v1.yaml | 256 ++++++++++++++++++ 28 files changed, 993 insertions(+), 47 deletions(-) create mode 100644 learning_resources/migrations/0068_learningresource_format_pace.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 17d9ff63e6..0a30546482 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -74,7 +74,7 @@ repos: - ".*/generated/" additional_dependencies: ["gibberish-detector"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.6.5" + rev: "v0.6.6" hooks: - id: ruff-format - id: ruff diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index 983db187e9..ff24f6357f 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -686,6 +686,18 @@ export interface CourseResource { * @memberof CourseResource */ resource_category: string + /** + * + * @type {Array} + * @memberof CourseResource + */ + format: Array + /** + * + * @type {Array} + * @memberof CourseResource + */ + pace: Array /** * * @type {CourseResourceResourceTypeEnum} @@ -844,6 +856,62 @@ export const CourseResourceDeliveryInnerCodeEnum = { export type CourseResourceDeliveryInnerCodeEnum = (typeof CourseResourceDeliveryInnerCodeEnum)[keyof typeof CourseResourceDeliveryInnerCodeEnum] +/** + * + * @export + * @interface CourseResourceFormatInner + */ +export interface CourseResourceFormatInner { + /** + * + * @type {string} + * @memberof CourseResourceFormatInner + */ + code: CourseResourceFormatInnerCodeEnum + /** + * + * @type {string} + * @memberof CourseResourceFormatInner + */ + name: string +} + +export const CourseResourceFormatInnerCodeEnum = { + Synchronous: "synchronous", + Asynchronous: "asynchronous", +} as const + +export type CourseResourceFormatInnerCodeEnum = + (typeof CourseResourceFormatInnerCodeEnum)[keyof typeof CourseResourceFormatInnerCodeEnum] + +/** + * + * @export + * @interface CourseResourcePaceInner + */ +export interface CourseResourcePaceInner { + /** + * + * @type {string} + * @memberof CourseResourcePaceInner + */ + code: CourseResourcePaceInnerCodeEnum + /** + * + * @type {string} + * @memberof CourseResourcePaceInner + */ + name: string +} + +export const CourseResourcePaceInnerCodeEnum = { + SelfPaced: "self_paced", + InstructorPaced: "instructor_paced", +} as const + +export type CourseResourcePaceInnerCodeEnum = + (typeof CourseResourcePaceInnerCodeEnum)[keyof typeof CourseResourcePaceInnerCodeEnum] + /** * Serializer for course resources * @export @@ -1376,6 +1444,18 @@ export interface LearningPathResource { * @memberof LearningPathResource */ resource_category: string + /** + * + * @type {Array} + * @memberof LearningPathResource + */ + format: Array + /** + * + * @type {Array} + * @memberof LearningPathResource + */ + pace: Array /** * * @type {LearningPathResourceResourceTypeEnum} @@ -2100,6 +2180,18 @@ export interface LearningResourceRun { * @memberof LearningResourceRun */ delivery: Array + /** + * + * @type {Array} + * @memberof LearningResourceRun + */ + format: Array + /** + * + * @type {Array} + * @memberof LearningResourceRun + */ + pace: Array /** * * @type {string} @@ -4049,6 +4141,18 @@ export interface PodcastEpisodeResource { * @memberof PodcastEpisodeResource */ resource_category: string + /** + * + * @type {Array} + * @memberof PodcastEpisodeResource + */ + format: Array + /** + * + * @type {Array} + * @memberof PodcastEpisodeResource + */ + pace: Array /** * * @type {PodcastEpisodeResourceResourceTypeEnum} @@ -4395,6 +4499,18 @@ export interface PodcastResource { * @memberof PodcastResource */ resource_category: string + /** + * + * @type {Array} + * @memberof PodcastResource + */ + format: Array + /** + * + * @type {Array} + * @memberof PodcastResource + */ + pace: Array /** * * @type {PodcastResourceResourceTypeEnum} @@ -4961,6 +5077,18 @@ export interface ProgramResource { * @memberof ProgramResource */ resource_category: string + /** + * + * @type {Array} + * @memberof ProgramResource + */ + format: Array + /** + * + * @type {Array} + * @memberof ProgramResource + */ + pace: Array /** * * @type {ProgramResourceResourceTypeEnum} @@ -5786,6 +5914,18 @@ export interface VideoPlaylistResource { * @memberof VideoPlaylistResource */ resource_category: string + /** + * + * @type {Array} + * @memberof VideoPlaylistResource + */ + format: Array + /** + * + * @type {Array} + * @memberof VideoPlaylistResource + */ + pace: Array /** * * @type {VideoPlaylistResourceResourceTypeEnum} @@ -6120,6 +6260,18 @@ export interface VideoResource { * @memberof VideoResource */ resource_category: string + /** + * + * @type {Array} + * @memberof VideoResource + */ + format: Array + /** + * + * @type {Array} + * @memberof VideoResource + */ + pace: Array /** * * @type {VideoResourceResourceTypeEnum} diff --git a/frontends/api/src/test-utils/factories/learningResources.ts b/frontends/api/src/test-utils/factories/learningResources.ts index 8c11ddf195..f37c0092fb 100644 --- a/frontends/api/src/test-utils/factories/learningResources.ts +++ b/frontends/api/src/test-utils/factories/learningResources.ts @@ -30,6 +30,8 @@ import type { import { AvailabilityEnum, DeliveryEnum, + CourseResourcePaceInnerCodeEnum, + CourseResourceFormatInnerCodeEnum, ResourceTypeEnum, LearningResourceRunLevelInnerCodeEnum, PlatformEnum, @@ -186,6 +188,22 @@ const learningResourceRun: Factory = (overrides = {}) => { name: uniqueEnforcerWords.enforce(() => faker.lorem.words()), }, ], + pace: [ + { + code: faker.helpers.arrayElement( + Object.values(CourseResourcePaceInnerCodeEnum), + ), + name: uniqueEnforcerWords.enforce(() => faker.lorem.words()), + }, + ], + format: [ + { + code: faker.helpers.arrayElement( + Object.values(CourseResourceFormatInnerCodeEnum), + ), + name: uniqueEnforcerWords.enforce(() => faker.lorem.words()), + }, + ], level: [ { code: faker.helpers.arrayElement( diff --git a/learning_resources/constants.py b/learning_resources/constants.py index cdc609d824..41e84d6edb 100644 --- a/learning_resources/constants.py +++ b/learning_resources/constants.py @@ -287,3 +287,17 @@ class CertificationType(ExtendedEnum): professional = "Professional Certificate" completion = "Certificate of Completion" none = "No Certificate" + + +class Pace(ExtendedEnum): + """Enum for resource pace types""" + + self_paced = "Self-paced" + instructor_paced = "Instructor-paced" + + +class Format(ExtendedEnum): + """Enum for resource format types""" + + synchronous = "Synchronous" + asynchronous = "Asynchronous" diff --git a/learning_resources/etl/constants.py b/learning_resources/etl/constants.py index 319f85f245..fd1e7fcc6a 100644 --- a/learning_resources/etl/constants.py +++ b/learning_resources/etl/constants.py @@ -46,7 +46,7 @@ ) -class ETLSource(Enum): +class ETLSource(ExtendedEnum): """Enum of ETL sources""" micromasters = "micromasters" diff --git a/learning_resources/etl/micromasters.py b/learning_resources/etl/micromasters.py index b4bec1232e..6bebb30d35 100644 --- a/learning_resources/etl/micromasters.py +++ b/learning_resources/etl/micromasters.py @@ -8,12 +8,13 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceType, OfferedBy, PlatformType, ) from learning_resources.etl.constants import COMMON_HEADERS, ETLSource -from learning_resources.models import LearningResource +from learning_resources.models import LearningResource, default_pace OFFERED_BY = {"code": OfferedBy.mitx.name} READABLE_ID_PREFIX = "micromasters-program-" @@ -46,6 +47,27 @@ def _is_published(course_id: str) -> bool: return False +def _get_course_pace(course_id: str) -> list[str]: + """ + Determine the pace of the course by id + + Args: + course_id (str): the course id + + Returns: + list[str]: the pace of the course as a list of strings + + """ + existing_course = LearningResource.objects.filter( + readable_id=course_id, + resource_type=LearningResourceType.course.name, + published=True, + ).first() + if existing_course: + return existing_course.pace + return default_pace() + + def _transform_image(micromasters_data: dict) -> dict: """ Transform an image into our normalized data structure @@ -65,7 +87,36 @@ def transform(programs_data): programs = [] for program in programs_data: url = program.get("programpage_url") + # need positioning of courses by course_id for course data + courses = [ + { + "readable_id": course["edx_key"], + "platform": PlatformType.edx.name, + "offered_by": OFFERED_BY, + "published": _is_published(course["edx_key"]), + "pace": _get_course_pace(course["edx_key"]), + "runs": [ + { + "run_id": run["edx_course_key"], + } + for run in course["course_runs"] + if run.get("edx_course_key", None) + ], + } + for course in sorted( + program["courses"], + key=lambda course: course["position_in_program"], + ) + ] if url and DEDP not in url: + pace = sorted( + { + pace + for course in courses + for pace in course["pace"] + if course["published"] + } + ) programs.append( { "readable_id": f"{READABLE_ID_PREFIX}{program['id']}", @@ -91,30 +142,15 @@ def transform(programs_data): "end_date": program["end_date"], "enrollment_start": program["enrollment_start"], "availability": Availability.dated.name, + "pace": pace, + "format": [Format.asynchronous.name], } ], "topics": program["topics"], "availability": Availability.dated.name, - # only need positioning of courses by course_id for course data - "courses": [ - { - "readable_id": course["edx_key"], - "platform": PlatformType.edx.name, - "offered_by": OFFERED_BY, - "published": _is_published(course["edx_key"]), - "runs": [ - { - "run_id": run["edx_course_key"], - } - for run in course["course_runs"] - if run.get("edx_course_key", None) - ], - } - for course in sorted( - program["courses"], - key=lambda course: course["position_in_program"], - ) - ], + "pace": pace, + "format": [Format.asynchronous.name], + "courses": courses, } ) return programs diff --git a/learning_resources/etl/micromasters_test.py b/learning_resources/etl/micromasters_test.py index ede2c8df77..343d61260c 100644 --- a/learning_resources/etl/micromasters_test.py +++ b/learning_resources/etl/micromasters_test.py @@ -6,7 +6,9 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceType, + Pace, PlatformType, ) from learning_resources.etl import micromasters @@ -111,6 +113,7 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): readable_id="1", resource_type=LearningResourceType.course.name, etl_source=ETLSource.mit_edx.name, + pace=[Pace.instructor_paced.name], ) if missing_url: mock_micromasters_data[0]["programpage_url"] = None @@ -129,6 +132,8 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): "certification": True, "certification_type": CertificationType.micromasters.name, "availability": Availability.dated.name, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], "courses": [ { "readable_id": "1", @@ -140,6 +145,7 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): "run_id": "course_key_1", } ], + "pace": [Pace.instructor_paced.name], }, { "readable_id": "2", @@ -147,6 +153,7 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): "offered_by": micromasters.OFFERED_BY, "published": False, "runs": [], + "pace": [Pace.self_paced.name], }, ], "runs": [ @@ -162,6 +169,8 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): "end_date": None, "enrollment_start": "2019-09-29T20:13:26.367297Z", "availability": Availability.dated.name, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } ], "topics": [{"name": "program"}, {"name": "first"}], diff --git a/learning_resources/etl/mitxonline.py b/learning_resources/etl/mitxonline.py index 7495bd9f12..296174d960 100644 --- a/learning_resources/etl/mitxonline.py +++ b/learning_resources/etl/mitxonline.py @@ -12,8 +12,10 @@ from learning_resources.constants import ( CertificationType, + Format, LearningResourceType, OfferedBy, + Pace, PlatformType, RunStatus, ) @@ -237,6 +239,12 @@ def _transform_run(course_run: dict, course: dict) -> dict: if parse_page_attribute(course, "page_url") else RunStatus.archived.value, "availability": course.get("availability"), + "format": [Format.asynchronous.name], + "pace": [ + Pace.self_paced.name + if course_run.get("is_self_paced", False) + else Pace.instructor_paced.name + ], } @@ -283,6 +291,8 @@ def _transform_course(course): "url": parse_page_attribute(course, "page_url", is_url=True), "description": clean_data(parse_page_attribute(course, "description")), "availability": course.get("availability"), + "format": [Format.asynchronous.name], + "pace": sorted({pace for run in runs for pace in run["pace"]}), } @@ -320,12 +330,30 @@ def _fetch_courses_by_ids(course_ids): return [] -def transform_programs(programs): - """Transform the MITX Online catalog data""" - # normalize the MITx Online data +def transform_programs(programs: list[dict]) -> list[dict]: + """ + Transform the MITX Online catalog data - return [ - { + Args: + programs (list of dict): the MITX Online programs data + + Returns: + list of dict: the transformed programs data + + """ + # normalize the MITx Online data + for program in programs: + courses = transform_courses( + [ + course + for course in _fetch_courses_by_ids(program["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"]} + ) + yield { "readable_id": program["readable_id"], "title": program["title"], "offered_by": OFFERED_BY, @@ -346,6 +374,8 @@ def transform_programs(programs): "published": bool( parse_page_attribute(program, "page_url") ), # a program is only considered published if it has a page url + "format": [Format.asynchronous.name], + "pace": pace, "runs": [ { "run_id": program["readable_id"], @@ -371,15 +401,9 @@ def transform_programs(programs): if parse_page_attribute(program, "page_url") else RunStatus.archived.value, "availability": program.get("availability"), + "format": [Format.asynchronous.name], + "pace": pace, } ], - "courses": transform_courses( - [ - course - for course in _fetch_courses_by_ids(program["courses"]) - if not re.search(EXCLUDE_REGEX, course["title"], re.IGNORECASE) - ] - ), + "courses": courses, } - for program in programs - ] diff --git a/learning_resources/etl/mitxonline_test.py b/learning_resources/etl/mitxonline_test.py index 637cc81209..a7364d393c 100644 --- a/learning_resources/etl/mitxonline_test.py +++ b/learning_resources/etl/mitxonline_test.py @@ -11,7 +11,9 @@ from learning_resources.constants import ( CertificationType, + Format, LearningResourceType, + Pace, PlatformType, RunStatus, ) @@ -150,6 +152,8 @@ def test_mitxonline_transform_programs( "url": parse_page_attribute(program_data, "page_url", is_url=True), "availability": program_data["availability"], "topics": transform_topics(program_data["topics"], OFFERED_BY["code"]), + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], "runs": [ { "run_id": program_data["readable_id"], @@ -171,6 +175,8 @@ def test_mitxonline_transform_programs( if parse_page_attribute(program_data, "page_url") else RunStatus.archived.value, "availability": program_data["availability"], + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } ], "courses": [ @@ -205,6 +211,8 @@ def test_mitxonline_transform_programs( "certification_type": CertificationType.completion.name, "url": parse_page_attribute(course_data, "page_url", is_url=True), "availability": course_data["availability"], + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], "topics": transform_topics( course_data["topics"], OFFERED_BY["code"] ), @@ -250,6 +258,8 @@ def test_mitxonline_transform_programs( if parse_page_attribute(course_data, "page_url") else RunStatus.archived.value, "availability": course_data["availability"], + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } for course_run_data in course_data["courseruns"] ], @@ -379,6 +389,8 @@ def test_mitxonline_transform_courses(settings, mock_mitxonline_courses_data): if parse_page_attribute(course_data, "page_url") else RunStatus.archived.value, "availability": course_data["availability"], + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } for course_run_data in course_data["courseruns"] ], @@ -394,6 +406,8 @@ def test_mitxonline_transform_courses(settings, mock_mitxonline_courses_data): ] }, "availability": course_data["availability"], + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } for course_data in mock_mitxonline_courses_data["results"] if "PROCTORED EXAM" not in course_data["title"] @@ -463,7 +477,9 @@ def test_program_run_start_date_value( # noqa: PLR0913 mock_mitxonline_programs_data["results"][0]["start_date"] = start_dt mock_mitxonline_programs_data["results"][0]["enrollment_start"] = enrollment_dt - transformed_programs = transform_programs(mock_mitxonline_programs_data["results"]) + transformed_programs = list( + transform_programs(mock_mitxonline_programs_data["results"]) + ) assert transformed_programs[0]["runs"][0]["start_date"] == _parse_datetime( expected_dt diff --git a/learning_resources/etl/ocw.py b/learning_resources/etl/ocw.py index f11310b8e2..d776f63c82 100644 --- a/learning_resources/etl/ocw.py +++ b/learning_resources/etl/ocw.py @@ -20,9 +20,11 @@ CONTENT_TYPE_VIDEO, VALID_TEXT_FILE_TYPES, Availability, + Format, LearningResourceDelivery, LearningResourceType, OfferedBy, + Pace, PlatformType, RunStatus, ) @@ -285,6 +287,8 @@ def transform_run(course_data: dict) -> dict: "url": course_data["url"], "availability": Availability.anytime.name, "delivery": parse_delivery(course_data), + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } @@ -368,6 +372,8 @@ def transform_course(course_data: dict) -> dict: "availability": Availability.anytime.name, "delivery": parse_delivery(course_data), "license_cc": True, + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } diff --git a/learning_resources/etl/ocw_test.py b/learning_resources/etl/ocw_test.py index 5d2ba82828..d54b562ce7 100644 --- a/learning_resources/etl/ocw_test.py +++ b/learning_resources/etl/ocw_test.py @@ -12,7 +12,9 @@ from learning_resources.constants import ( DEPARTMENTS, Availability, + Format, LearningResourceDelivery, + Pace, ) from learning_resources.etl.constants import CourseNumberType, ETLSource from learning_resources.etl.ocw import ( @@ -248,6 +250,10 @@ def test_transform_course( # noqa: PLR0913 assert transformed_json["runs"][0]["delivery"] == expected_delivery assert transformed_json["runs"][0]["availability"] == Availability.anytime.name assert transformed_json["availability"] == Availability.anytime.name + assert transformed_json["runs"][0]["pace"] == [Pace.self_paced.name] + assert transformed_json["runs"][0]["format"] == [Format.asynchronous.name] + assert transformed_json["pace"] == [Pace.self_paced.name] + assert transformed_json["format"] == [Format.asynchronous.name] assert transformed_json["description"] == clean_data( course_json["course_description_html"] ) diff --git a/learning_resources/etl/oll.py b/learning_resources/etl/oll.py index 5c720cbafe..96bd8436b3 100644 --- a/learning_resources/etl/oll.py +++ b/learning_resources/etl/oll.py @@ -13,7 +13,9 @@ from learning_resources.constants import ( Availability, + Format, OfferedBy, + Pace, PlatformType, RunStatus, ) @@ -143,6 +145,8 @@ def transform_run(course_data: dict) -> list[dict]: ], "status": RunStatus.archived.value, "availability": Availability.anytime.name, + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } ] @@ -183,6 +187,8 @@ def transform_course(course_data: dict) -> dict: "prices": [Decimal(0.00)], "etl_source": ETLSource.oll.name, "availability": Availability.anytime.name, + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } diff --git a/learning_resources/etl/oll_test.py b/learning_resources/etl/oll_test.py index f0eebf8ea6..91e9e05c7d 100644 --- a/learning_resources/etl/oll_test.py +++ b/learning_resources/etl/oll_test.py @@ -73,6 +73,8 @@ def test_oll_transform(mocker, oll_course_data): "year": 2022, "status": "Archived", "availability": "anytime", + "pace": ["self_paced"], + "format": ["asynchronous"], } ], "image": { @@ -82,6 +84,8 @@ def test_oll_transform(mocker, oll_course_data): "prices": [0.00], "etl_source": "oll", "availability": "anytime", + "pace": ["self_paced"], + "format": ["asynchronous"], } assert results[2] == { "title": "Competency-Based Education", @@ -125,6 +129,8 @@ def test_oll_transform(mocker, oll_course_data): "year": 2019, "status": "Archived", "availability": "anytime", + "pace": ["self_paced"], + "format": ["asynchronous"], } ], "image": { @@ -134,4 +140,6 @@ def test_oll_transform(mocker, oll_course_data): "prices": [0.00], "etl_source": "oll", "availability": "anytime", + "pace": ["self_paced"], + "format": ["asynchronous"], } diff --git a/learning_resources/etl/openedx.py b/learning_resources/etl/openedx.py index c7cc98b532..18a1ce118c 100644 --- a/learning_resources/etl/openedx.py +++ b/learning_resources/etl/openedx.py @@ -18,7 +18,9 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceType, + Pace, PlatformType, RunStatus, ) @@ -290,7 +292,7 @@ def _parse_course_dates(program, date_field): [ run[date_field] for run in course["course_runs"] - if run["status"] == "published" and run[date_field] + if _get_run_published(run) and run[date_field] ] ) return dates @@ -312,7 +314,7 @@ def _get_course_price(course): for run in sorted( course["course_runs"], key=lambda x: x["start"], reverse=False ): - if run["status"] == "published": + if _get_run_published(run): return min( [ Decimal(seat["price"]) @@ -356,6 +358,8 @@ def _transform_course_run(config, course_run, course_last_modified, marketing_ur "image": _transform_course_image(course_run.get("image")), "status": course_run.get("availability"), "availability": _get_run_availability(course_run).name, + "format": [Format.asynchronous.name], + "pace": [course_run.get("pacing_type") or Pace.self_paced.name], "url": marketing_url or "{}{}/course/".format(config.alt_url, course_run.get("key")), "prices": sorted( @@ -401,6 +405,25 @@ def _parse_program_instructors_topics(program): ) +def _parse_course_pace(runs: list[dict]) -> list[str]: + """ + Parse the pace of a course based on its runs + + Args: + runs (list of dict): the runs data + + Returns: + str: the pace of the course or programe + """ + pace = sorted( + {run["pacing_type"] for run in runs if run and _get_run_published(run)} + ) + if len(pace) == 0: + # Archived courses are considered self-paced + pace = [Pace.self_paced.name] + return pace + + def _transform_program_course(config: OpenEdxConfiguration, course: dict) -> dict: """ Transform a program's course dict to a normalized data structure @@ -419,6 +442,7 @@ def _transform_program_course(config: OpenEdxConfiguration, course: dict) -> dic "platform": config.platform, "resource_type": LearningResourceType.course.name, "offered_by": {"code": config.offered_by}, + "pace": _parse_course_pace(course.get("course_runs", [])), } @@ -458,6 +482,14 @@ def _transform_program_run( "prices": [_sum_course_prices(program)], "instructors": program.pop("instructors", []), "availability": _get_program_availability(program), + "format": [Format.asynchronous.name], + "pace": sorted( + { + pace + for course in program.get("courses", []) + for pace in _parse_course_pace(course.get("course_runs", [])) + } + ), } @@ -507,6 +539,8 @@ def _transform_course(config: OpenEdxConfiguration, course: dict) -> dict: if has_certification else CertificationType.none.name, "availability": _get_course_availability(course), + "format": [Format.asynchronous.name], + "pace": _parse_course_pace(course.get("course_runs", [])), } @@ -526,6 +560,11 @@ def _transform_program(config: OpenEdxConfiguration, program: dict) -> dict: image = _transform_program_image(program) instructors, topics = _parse_program_instructors_topics(program) program["instructors"] = instructors + courses = [ + _transform_program_course(config, course) + for course in program.get("courses", []) + ] + paces = sorted({pace for course in courses for pace in course["pace"]}) runs = [_transform_program_run(program, last_modified, image)] has_certification = parse_certification(config.offered_by, runs) return { @@ -549,10 +588,9 @@ def _transform_program(config: OpenEdxConfiguration, program: dict) -> dict: if has_certification else CertificationType.none.name, "availability": runs[0]["availability"], - "courses": [ - _transform_program_course(config, course) - for course in program.get("courses", []) - ], + "format": [Format.asynchronous.name], + "pace": paces, + "courses": courses, } diff --git a/learning_resources/etl/openedx_test.py b/learning_resources/etl/openedx_test.py index 3503cc15c4..3ae662fbe7 100644 --- a/learning_resources/etl/openedx_test.py +++ b/learning_resources/etl/openedx_test.py @@ -10,8 +10,10 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceType, OfferedBy, + Pace, PlatformType, RunStatus, ) @@ -212,6 +214,13 @@ def test_transform_course( # noqa: PLR0913 "last_modified": any_instance_of(datetime), "topics": [{"name": "Data Analysis & Statistics"}], "url": "http://localhost/fake-alt-url/this_course", + "format": [Format.asynchronous.name], + "pace": [Pace.instructor_paced.name] + if has_runs + and not is_run_deleted + and is_run_published + and is_run_enrollable + else [Pace.self_paced.name], "published": is_run_published and is_run_enrollable and not is_run_deleted @@ -249,6 +258,8 @@ def test_transform_course( # noqa: PLR0913 "year": 2019, "published": is_run_enrollable and is_run_published, "availability": Availability.dated.name, + "format": [Format.asynchronous.name], + "pace": [Pace.instructor_paced.name], } ] ), @@ -422,6 +433,8 @@ def test_transform_program( "certification": False, "certification_type": CertificationType.none.name, "availability": Availability.anytime.name, + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], "runs": ( [ { @@ -450,6 +463,8 @@ def test_transform_program( "url": extracted[0]["marketing_url"], "published": True, "availability": Availability.anytime.name, + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } ] ), @@ -460,6 +475,7 @@ def test_transform_program( "platform": openedx_program_config.platform, "readable_id": f"MITx+6.002.{i}x", "resource_type": LearningResourceType.course.name, + "pace": [Pace.self_paced.name], } for i in range(1, 4) ], diff --git a/learning_resources/etl/prolearn.py b/learning_resources/etl/prolearn.py index de00d3b9d3..022aa6cc62 100644 --- a/learning_resources/etl/prolearn.py +++ b/learning_resources/etl/prolearn.py @@ -9,7 +9,7 @@ import requests from django.conf import settings -from learning_resources.constants import Availability, CertificationType +from learning_resources.constants import Availability, CertificationType, Format, Pace from learning_resources.etl.constants import ETLSource from learning_resources.etl.utils import transform_delivery, transform_topics from learning_resources.models import LearningResourceOfferor, LearningResourcePlatform @@ -281,10 +281,14 @@ def transform_programs(programs: list[dict]) -> list[dict]: } ], "unique_field": UNIQUE_FIELD, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for course_id in sorted(program["field_related_courses_programs"]) ], "unique_field": UNIQUE_FIELD, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } unique_program = unique_programs.setdefault( transformed_program["url"], transformed_program @@ -321,6 +325,8 @@ def _transform_runs(resource: dict) -> list[dict]: "url": parse_url(resource), "delivery": transform_delivery(resource["format_name"]), "availability": Availability.dated.name, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } ) return runs @@ -361,6 +367,8 @@ def _transform_course( "runs": runs, "unique_field": UNIQUE_FIELD, "availability": Availability.dated.name, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } return None diff --git a/learning_resources/etl/prolearn_test.py b/learning_resources/etl/prolearn_test.py index 00eb8fc267..9390b73a48 100644 --- a/learning_resources/etl/prolearn_test.py +++ b/learning_resources/etl/prolearn_test.py @@ -10,8 +10,10 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceDelivery, OfferedBy, + Pace, PlatformType, ) from learning_resources.etl.constants import ETLSource @@ -177,6 +179,8 @@ def test_prolearn_transform_programs(mock_csail_programs_data): ), "availability": Availability.dated.name, "delivery": transform_delivery(program["format_name"]), + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for (start_val, end_val) in zip( program["start_value"], program["end_value"] @@ -201,10 +205,14 @@ def test_prolearn_transform_programs(mock_csail_programs_data): } ], "unique_field": UNIQUE_FIELD, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for course_id in sorted(program["field_related_courses_programs"]) ], "unique_field": UNIQUE_FIELD, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for program in extracted_data[1:] ] @@ -254,6 +262,8 @@ def test_prolearn_transform_courses(mock_mitpe_courses_data): ), "availability": Availability.dated.name, "delivery": transform_delivery(course["format_name"]), + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for (start_val, end_val) in zip( course["start_value"], course["end_value"] @@ -261,6 +271,8 @@ def test_prolearn_transform_courses(mock_mitpe_courses_data): ], "course": {"course_numbers": []}, "unique_field": UNIQUE_FIELD, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for course in extracted_data[2:] ] diff --git a/learning_resources/etl/sloan.py b/learning_resources/etl/sloan.py index 3697c5690d..a58b4e311e 100644 --- a/learning_resources/etl/sloan.py +++ b/learning_resources/etl/sloan.py @@ -12,7 +12,9 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, OfferedBy, + Pace, PlatformType, RunStatus, ) @@ -21,6 +23,7 @@ transform_delivery, transform_topics, ) +from learning_resources.models import default_format log = logging.getLogger(__name__) @@ -124,6 +127,49 @@ def parse_availability(run_data: dict) -> str: return Availability.dated.name +def parse_pace(run_data: dict) -> str: + """ + Parse pace from run data + + Args: + run_data (list): the run data + + Returns: + str: the pace + """ + if run_data and ( + run_data.get("Delivery") == "Online" + and run_data.get("Format") == "Asynchronous (On-Demand)" + ): + return Pace.self_paced.name + return Pace.instructor_paced.name + + +def parse_format(run_data: dict) -> str: + """ + Parse format from run data + + Args: + run_data (list): the run data + + Returns: + str: the format code + """ + if run_data: + delivery = run_data.get("Delivery") + if delivery == "In Person": + return [Format.synchronous.name] + elif delivery == "Blended": + return [Format.synchronous.name, Format.asynchronous.name] + else: + return ( + [Format.asynchronous.name] + if "Asynchronous" in (run_data.get("Format") or "") + else [Format.synchronous.name] + ) + return default_format() + + def extract(): """ Extract Sloan Executive Education data @@ -184,6 +230,8 @@ def transform_run(run_data, course_data): "published": True, "prices": [run_data["Price"]], "instructors": [{"full_name": name.strip()} for name in faculty_names], + "pace": [parse_pace(run_data)], + "format": parse_format(run_data), } @@ -205,6 +253,8 @@ def transform_course(course_data: dict, runs_data: dict) -> dict: format_delivery = list( {transform_delivery(run["Delivery"])[0] for run in course_runs_data} ) + runs = [transform_run(run, course_data) for run in course_runs_data] + transformed_course = { "readable_id": course_data["Course_Id"], "title": course_data["Title"], @@ -223,8 +273,10 @@ def transform_course(course_data: dict, runs_data: dict) -> dict: "course": { "course_numbers": [], }, - "runs": [transform_run(run, course_data) for run in course_runs_data], + "runs": runs, "continuing_ed_credits": course_runs_data[0]["Continuing_Ed_Credits"], + "pace": sorted({pace for run in runs for pace in run["pace"]}), + "format": sorted({run_format for run in runs for run_format in run["format"]}), } return transformed_course if transformed_course.get("url") else None diff --git a/learning_resources/etl/sloan_test.py b/learning_resources/etl/sloan_test.py index 9cb6a6cfa6..85c472d15e 100644 --- a/learning_resources/etl/sloan_test.py +++ b/learning_resources/etl/sloan_test.py @@ -7,13 +7,17 @@ from learning_resources.constants import ( Availability, + Format, + Pace, RunStatus, ) from learning_resources.etl.sloan import ( extract, parse_availability, parse_datetime, + parse_format, parse_image, + parse_pace, transform_course, transform_delivery, transform_run, @@ -124,6 +128,8 @@ def test_transform_run( "published": True, "prices": [run_data["Price"]], "instructors": [{"full_name": name.strip()} for name in faculty_names], + "pace": [Pace.instructor_paced.name], + "format": [Format.synchronous.name], } @@ -147,6 +153,10 @@ def test_transform_course(mock_sloan_courses_data, mock_sloan_runs_data): assert transformed["runs"][0]["availability"] == parse_availability( course_runs_data[0] ) + assert transformed["pace"] == [Pace.instructor_paced.name] + assert transformed["format"] == [Format.asynchronous.name, Format.synchronous.name] + assert transformed["runs"][0]["pace"] == [Pace.instructor_paced.name] + assert transformed["runs"][0]["format"] == [Format.asynchronous.name] assert transformed["image"] == parse_image(course_data) assert ( transformed["continuing_ed_credits"] @@ -171,6 +181,8 @@ def test_transform_course(mock_sloan_courses_data, mock_sloan_runs_data): "course", "runs", "continuing_ed_credits", + "pace", + "format", ] ) @@ -223,3 +235,48 @@ def test_enabled_flag(mock_sloan_api_setting, settings): """Extract should return empty lists if the SEE_API_ENABLED flag is False""" settings.SEE_API_ENABLED = False assert extract() == ([], []) + + +@pytest.mark.parametrize( + ("delivery", "run_format", "pace"), + [ + ("Online", "Synchronous", Pace.instructor_paced.name), + ("Online", "Asynchronous (On-Demand)", Pace.self_paced.name), + ("Online", "Asynchronous (Date based)", Pace.instructor_paced.name), + ("In Person", "Asynchronous (On-Demand)", Pace.instructor_paced.name), + ], +) +def test_parse_pace(delivery, run_format, pace): + """Test that the pace is parsed correctly""" + run_data = { + "Format": run_format, + "Delivery": delivery, + } + assert parse_pace(run_data) == pace + assert parse_pace(None) == Pace.instructor_paced.name + + +@pytest.mark.parametrize( + ("delivery", "run_format", "expected_format"), + [ + ("In Person", "Asynchronous (On-Demand)", [Format.synchronous.name]), + ( + "Blended", + "Asynchronous (On-Demand)", + [Format.synchronous.name, Format.asynchronous.name], + ), + ("Online", "Synchronous", [Format.synchronous.name]), + ("Online", "Asynchronous (On-Demand)", [Format.asynchronous.name]), + ("Online", "Asynchronous (Date based)", [Format.asynchronous.name]), + ("Online", None, [Format.synchronous.name]), + (None, None, [Format.synchronous.name]), + ], +) +def test_parse_format(delivery, run_format, expected_format): + """Test that the format is parsed correctly""" + run_data = { + "Format": run_format, + "Delivery": delivery, + } + assert parse_format(run_data) == expected_format + assert parse_format(None) == [Format.asynchronous.name] diff --git a/learning_resources/etl/xpro.py b/learning_resources/etl/xpro.py index 2dc7f08214..c6f3f4b225 100644 --- a/learning_resources/etl/xpro.py +++ b/learning_resources/etl/xpro.py @@ -10,8 +10,10 @@ from learning_resources.constants import ( CertificationType, + Format, LearningResourceType, OfferedBy, + Pace, PlatformType, ) from learning_resources.etl.constants import ETLSource @@ -117,6 +119,8 @@ def _transform_run(course_run: dict, course: dict) -> dict: ], "availability": course["availability"], "delivery": transform_delivery(course.get("format")), + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } @@ -158,6 +162,8 @@ def _transform_learning_resource_course(course): "certification_type": CertificationType.professional.name, "availability": course["availability"], "continuing_ed_credits": course["credits"], + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } @@ -216,6 +222,8 @@ def transform_programs(programs): ], "delivery": transform_delivery(program.get("format")), "availability": program["availability"], + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } ], "courses": transform_courses(program["courses"]), @@ -223,6 +231,8 @@ def transform_programs(programs): "certification_type": CertificationType.professional.name, "availability": program["availability"], "continuing_ed_credits": program["credits"], + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } for program in programs ] diff --git a/learning_resources/etl/xpro_test.py b/learning_resources/etl/xpro_test.py index 0f1954a5c2..7acb9e8226 100644 --- a/learning_resources/etl/xpro_test.py +++ b/learning_resources/etl/xpro_test.py @@ -10,7 +10,9 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceType, + Pace, PlatformType, ) from learning_resources.etl import xpro @@ -112,6 +114,8 @@ def test_xpro_transform_programs(mock_xpro_programs_data): "resource_type": LearningResourceType.program.name, "delivery": transform_delivery(program_data.get("format")), "continuing_ed_credits": program_data.get("credits"), + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], "runs": [ { "run_id": program_data["readable_id"], @@ -131,6 +135,8 @@ def test_xpro_transform_programs(mock_xpro_programs_data): "description": program_data["description"], "delivery": transform_delivery(program_data.get("format")), "availability": Availability.dated.name, + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } ], "courses": [ @@ -153,6 +159,8 @@ def test_xpro_transform_programs(mock_xpro_programs_data): "topics": parse_topics(course_data), "resource_type": LearningResourceType.course.name, "continuing_ed_credits": course_data.get("credits"), + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], "runs": [ { "run_id": course_run_data["courseware_id"], @@ -173,6 +181,8 @@ def test_xpro_transform_programs(mock_xpro_programs_data): ], "delivery": transform_delivery(course_data.get("format")), "availability": Availability.dated.name, + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } for course_run_data in course_data["courseruns"] ], @@ -245,6 +255,8 @@ def test_xpro_transform_courses(mock_xpro_courses_data): ], "delivery": transform_delivery(course_data.get("format")), "availability": Availability.dated.name, + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } for course_run_data in course_data["courseruns"] ], @@ -262,6 +274,8 @@ def test_xpro_transform_courses(mock_xpro_courses_data): "certification": True, "certification_type": CertificationType.professional.name, "continuing_ed_credits": course_data.get("credits"), + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } for course_data in mock_xpro_courses_data ] diff --git a/learning_resources/migrations/0068_learningresource_format_pace.py b/learning_resources/migrations/0068_learningresource_format_pace.py new file mode 100644 index 0000000000..6929be78d1 --- /dev/null +++ b/learning_resources/migrations/0068_learningresource_format_pace.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.16 on 2024-09-23 14:18 + +import django.contrib.postgres.fields +from django.db import migrations, models + +import learning_resources.models + + +class Migration(migrations.Migration): + dependencies = [ + ("learning_resources", "0067_ocw_delivery_online_only"), + ] + + operations = [ + migrations.AddField( + model_name="learningresource", + name="format", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("synchronous", "Synchronous"), + ("asynchronous", "Asynchronous"), + ], + max_length=24, + ), + default=learning_resources.models.default_format, + size=None, + ), + ), + migrations.AddField( + model_name="learningresource", + name="pace", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("self_paced", "Self-paced"), + ("instructor_paced", "Instructor-paced"), + ], + max_length=24, + ), + default=learning_resources.models.default_pace, + size=None, + ), + ), + migrations.AddField( + model_name="learningresourcerun", + name="format", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("synchronous", "Synchronous"), + ("asynchronous", "Asynchronous"), + ], + max_length=24, + ), + default=learning_resources.models.default_format, + size=None, + ), + ), + migrations.AddField( + model_name="learningresourcerun", + name="pace", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("self_paced", "Self-paced"), + ("instructor_paced", "Instructor-paced"), + ], + max_length=24, + ), + default=learning_resources.models.default_pace, + size=None, + ), + ), + ] diff --git a/learning_resources/models.py b/learning_resources/models.py index 069214b7a6..4b74e723cd 100644 --- a/learning_resources/models.py +++ b/learning_resources/models.py @@ -16,9 +16,11 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceDelivery, LearningResourceRelationTypes, LearningResourceType, + Pace, PrivacyLevel, ) from main.models import TimestampedModel, TimestampedModelQuerySet @@ -29,6 +31,16 @@ def default_delivery(): return [LearningResourceDelivery.online.name] +def default_pace(): + """Return the default pace as a list""" + return [Pace.self_paced.name] + + +def default_format(): + """Return the default format as a list""" + return [Format.asynchronous.name] + + class LearningResourcePlatform(TimestampedModel): """Platforms for all learning resources""" @@ -428,6 +440,20 @@ class LearningResource(TimestampedModel): continuing_ed_credits = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True ) + pace = ArrayField( + models.CharField( + max_length=24, + choices=Pace.as_tuple(), + ), + default=default_pace, + ) + format = ArrayField( + models.CharField( + max_length=24, + choices=Format.as_tuple(), + ), + default=default_format, + ) @property def audience(self) -> str | None: @@ -565,6 +591,20 @@ class LearningResourceRun(TimestampedModel): null=True, choices=Availability.as_tuple(), ) + pace = ArrayField( + models.CharField( + max_length=24, + choices=Pace.as_tuple(), + ), + default=default_pace, + ) + format = ArrayField( + models.CharField( + max_length=24, + choices=Format.as_tuple(), + ), + default=default_format, + ) def __str__(self): return f"LearningResourceRun platform={self.learning_resource.platform} run_id={self.run_id}" # noqa: E501 diff --git a/learning_resources/serializers.py b/learning_resources/serializers.py index 64b76380eb..c52a40ebc6 100644 --- a/learning_resources/serializers.py +++ b/learning_resources/serializers.py @@ -15,9 +15,11 @@ from learning_resources.constants import ( LEARNING_MATERIAL_RESOURCE_CATEGORY, CertificationType, + Format, LearningResourceDelivery, LearningResourceType, LevelType, + Pace, ) from learning_resources.etl.loaders import update_index from main.serializers import COMMON_IGNORED_FIELDS, WriteableSerializerMethodField @@ -236,6 +238,36 @@ def to_representation(self, value): return {"code": value, "name": LearningResourceDelivery[value].value} +@extend_schema_field( + { + "type": "object", + "properties": { + "code": {"enum": Format.names()}, + "name": {"type": "string"}, + }, + "required": ["code", "name"], + } +) +class FormatSerializer(serializers.Field): + def to_representation(self, value): + return {"code": value, "name": Format[value].value} + + +@extend_schema_field( + { + "type": "object", + "properties": { + "code": {"enum": Pace.names()}, + "name": {"type": "string"}, + }, + "required": ["code", "name"], + } +) +class PaceSerializer(serializers.Field): + def to_representation(self, value): + return {"code": value, "name": Pace[value].value} + + class LearningResourceRunSerializer(serializers.ModelSerializer): """Serializer for the LearningResourceRun model""" @@ -248,6 +280,8 @@ class LearningResourceRunSerializer(serializers.ModelSerializer): delivery = serializers.ListField( child=LearningResourceDeliverySerializer(), read_only=True ) + format = serializers.ListField(child=FormatSerializer(), read_only=True) + pace = serializers.ListField(child=PaceSerializer(), read_only=True) class Meta: model = models.LearningResourceRun @@ -415,6 +449,8 @@ class LearningResourceBaseSerializer(serializers.ModelSerializer, WriteableTopic ) free = serializers.SerializerMethodField() resource_category = serializers.SerializerMethodField() + format = serializers.ListField(child=FormatSerializer(), read_only=True) + pace = serializers.ListField(child=PaceSerializer(), read_only=True) def get_resource_category(self, instance) -> str: """Return the resource category of the resource""" diff --git a/learning_resources/serializers_test.py b/learning_resources/serializers_test.py index 3ca30f06dc..bc68711f96 100644 --- a/learning_resources/serializers_test.py +++ b/learning_resources/serializers_test.py @@ -12,9 +12,11 @@ from learning_resources.constants import ( LEARNING_MATERIAL_RESOURCE_CATEGORY, CertificationType, + Format, LearningResourceDelivery, LearningResourceRelationTypes, LearningResourceType, + Pace, PlatformType, ) from learning_resources.models import ContentFile, LearningResource @@ -262,6 +264,13 @@ def test_learning_resource_serializer( # noqa: PLR0913 {"code": lr_delivery, "name": LearningResourceDelivery[lr_delivery].value} for lr_delivery in resource.delivery ], + "format": [ + {"code": lr_format, "name": Format[lr_format].value} + for lr_format in resource.format + ], + "pace": [ + {"code": lr_pace, "name": Pace[lr_pace].value} for lr_pace in resource.pace + ], "next_start_date": resource.next_start_date, "availability": resource.availability, "completeness": 1.0, diff --git a/learning_resources_search/constants.py b/learning_resources_search/constants.py index b0b1d53051..a4208aaf4e 100644 --- a/learning_resources_search/constants.py +++ b/learning_resources_search/constants.py @@ -121,6 +121,20 @@ class FilterConfig: "name": {"type": "keyword"}, }, }, + "pace": { + "type": "nested", + "properties": { + "code": {"type": "keyword"}, + "name": {"type": "keyword"}, + }, + }, + "format": { + "type": "nested", + "properties": { + "code": {"type": "keyword"}, + "name": {"type": "keyword"}, + }, + }, "readable_id": {"type": "keyword"}, "title": ENGLISH_TEXT_FIELD_WITH_SUGGEST, "description": ENGLISH_TEXT_FIELD_WITH_SUGGEST, @@ -231,6 +245,20 @@ class FilterConfig: "name": {"type": "keyword"}, }, }, + "pace": { + "type": "nested", + "properties": { + "code": {"type": "keyword"}, + "name": {"type": "keyword"}, + }, + }, + "format": { + "type": "nested", + "properties": { + "code": {"type": "keyword"}, + "name": {"type": "keyword"}, + }, + }, "semester": {"type": "keyword"}, "year": {"type": "keyword"}, "start_date": {"type": "date"}, diff --git a/main/models.py b/main/models.py index 686cea755a..4bafc925ff 100644 --- a/main/models.py +++ b/main/models.py @@ -18,7 +18,7 @@ def update(self, **kwargs): Automatically update updated_on timestamp when .update(). This is because .update() does not go through .save(), thus will not auto_now, because it happens on the database level without loading objects into memory. - """ # noqa: D402, E501 + """ # noqa: E501 if "updated_on" not in kwargs: kwargs["updated_on"] = now_in_utc() return super().update(**kwargs) diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index ea686f47a4..2f6057ec71 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -7956,6 +7956,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/CourseResourceResourceTypeEnum' @@ -8023,11 +8053,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - prices - professional @@ -8398,6 +8430,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/LearningPathResourceResourceTypeEnum' @@ -8463,12 +8525,14 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path - learning_path_parents - offered_by + - pace - platform - prices - readable_id @@ -8964,6 +9028,36 @@ components: - code - name readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true run_id: type: string maxLength: 128 @@ -9040,10 +9134,12 @@ components: - $ref: '#/components/schemas/NullEnum' required: - delivery + - format - id - image - instructors - level + - pace - run_id - title LearningResourceRunRequest: @@ -10405,6 +10501,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/PodcastEpisodeResourceResourceTypeEnum' @@ -10471,11 +10597,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - podcast_episode - prices @@ -10687,6 +10815,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/PodcastResourceResourceTypeEnum' @@ -10753,11 +10911,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - podcast - prices @@ -11099,6 +11259,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/ProgramResourceResourceTypeEnum' @@ -11165,11 +11355,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - prices - professional @@ -11653,6 +11845,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/VideoPlaylistResourceResourceTypeEnum' @@ -11719,11 +11941,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - prices - professional @@ -11921,6 +12145,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/VideoResourceResourceTypeEnum' @@ -11987,11 +12241,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - prices - professional