diff --git a/fixtures/common.py b/fixtures/common.py index 4f18431505..b99af80de1 100644 --- a/fixtures/common.py +++ b/fixtures/common.py @@ -106,9 +106,12 @@ def offeror_featured_lists(): # noqa: PT004 is_course=True, ) if offered_by == OfferedBy.ocw.name: - LearningResourceRun.objects.filter( - learning_resource=resource.id - ).update(prices=[]) + for run in LearningResourceRun.objects.filter( + learning_resource__id=resource.id + ): + run.resource_prices.set([]) + run.prices = [] + run.save() featured_path.resources.add( resource, through_defaults={ diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index 1bed86fea1..29fac8663e 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -644,6 +644,12 @@ export interface CourseResource { * @memberof CourseResource */ prices: Array + /** + * + * @type {Array} + * @memberof CourseResource + */ + resource_prices: Array /** * * @type {Array} @@ -1432,6 +1438,12 @@ export interface LearningPathResource { * @memberof LearningPathResource */ prices: Array + /** + * + * @type {Array} + * @memberof LearningPathResource + */ + resource_prices: Array /** * * @type {Array} @@ -2147,6 +2159,44 @@ export interface LearningResourcePlatformRequest { */ name?: string } +/** + * Serializer for LearningResourcePrice model + * @export + * @interface LearningResourcePrice + */ +export interface LearningResourcePrice { + /** + * + * @type {string} + * @memberof LearningResourcePrice + */ + amount: string + /** + * + * @type {string} + * @memberof LearningResourcePrice + */ + currency: string +} +/** + * Serializer for LearningResourcePrice model + * @export + * @interface LearningResourcePriceRequest + */ +export interface LearningResourcePriceRequest { + /** + * + * @type {string} + * @memberof LearningResourcePriceRequest + */ + amount: string + /** + * + * @type {string} + * @memberof LearningResourcePriceRequest + */ + currency: string +} /** * CRUD serializer for LearningResourceRelationship * @export @@ -2252,6 +2302,12 @@ export interface LearningResourceRun { * @memberof LearningResourceRun */ pace: Array + /** + * + * @type {Array} + * @memberof LearningResourceRun + */ + resource_prices: Array /** * * @type {string} @@ -4201,6 +4257,12 @@ export interface PodcastEpisodeResource { * @memberof PodcastEpisodeResource */ prices: Array + /** + * + * @type {Array} + * @memberof PodcastEpisodeResource + */ + resource_prices: Array /** * * @type {Array} @@ -4589,6 +4651,12 @@ export interface PodcastResource { * @memberof PodcastResource */ prices: Array + /** + * + * @type {Array} + * @memberof PodcastResource + */ + resource_prices: Array /** * * @type {Array} @@ -5209,6 +5277,12 @@ export interface ProgramResource { * @memberof ProgramResource */ prices: Array + /** + * + * @type {Array} + * @memberof ProgramResource + */ + resource_prices: Array /** * * @type {Array} @@ -6076,6 +6150,12 @@ export interface VideoPlaylistResource { * @memberof VideoPlaylistResource */ prices: Array + /** + * + * @type {Array} + * @memberof VideoPlaylistResource + */ + resource_prices: Array /** * * @type {Array} @@ -6452,6 +6532,12 @@ export interface VideoResource { * @memberof VideoResource */ prices: Array + /** + * + * @type {Array} + * @memberof VideoResource + */ + resource_prices: Array /** * * @type {Array} diff --git a/frontends/api/src/test-utils/factories/learningResources.ts b/frontends/api/src/test-utils/factories/learningResources.ts index 3e4193a455..21c78495ab 100644 --- a/frontends/api/src/test-utils/factories/learningResources.ts +++ b/frontends/api/src/test-utils/factories/learningResources.ts @@ -16,6 +16,7 @@ import type { LearningResourceInstructor, LearningResourceOfferorDetail, LearningResourcePlatform, + LearningResourcePrice, LearningResourceRun, LearningResourceSchool, LearningResourceTopic, @@ -84,6 +85,17 @@ const learningResourceInstructor: Factory = ( return instructor } +const learningResourcePrice: Factory = ( + overrides = {}, +) => { + const resourcePrice: LearningResourcePrice = { + amount: faker.finance.amount({ min: 0, max: 200 }), + currency: "USD", + ...overrides, + } + return resourcePrice +} + const learningResourceBaseSchool: Factory = ( overrides = {}, ) => { @@ -212,6 +224,7 @@ const learningResourceRun: Factory = (overrides = {}) => { name: uniqueEnforcerWords.enforce(() => faker.lorem.words()), }, ], + resource_prices: repeat(learningResourcePrice, { min: 0, max: 5 }), ...overrides, } return run @@ -264,6 +277,14 @@ const _learningResourceShared = (): Partial< platform: maybe(learningResourcePlatform) ?? null, free, prices: free ? ["0"] : [faker.finance.amount({ min: 0, max: 100 })], + resource_prices: free + ? [{ amount: "0", currency: "USD" }] + : [ + { + amount: faker.finance.amount({ min: 0, max: 100 }).toString(), + currency: "USD", + }, + ], readable_id: faker.lorem.slug(), course_feature: repeat(faker.lorem.word), runs: [], diff --git a/learning_resources/constants.py b/learning_resources/constants.py index 41e84d6edb..2f045612e7 100644 --- a/learning_resources/constants.py +++ b/learning_resources/constants.py @@ -301,3 +301,6 @@ class Format(ExtendedEnum): synchronous = "Synchronous" asynchronous = "Asynchronous" + + +CURRENCY_USD = "USD" diff --git a/learning_resources/etl/constants.py b/learning_resources/etl/constants.py index 339e83c291..140433f2a3 100644 --- a/learning_resources/etl/constants.py +++ b/learning_resources/etl/constants.py @@ -10,6 +10,7 @@ from named_enum import ExtendedEnum from learning_resources.constants import LearningResourceDelivery +from learning_resources.models import LearningResourcePrice # A custom UA so that operators of OpenEdx will know who is pinging their service COMMON_HEADERS = { @@ -115,5 +116,6 @@ class ContentTagCategory(ExtendedEnum): class ResourceNextRunConfig: next_start_date: datetime = None prices: list[Decimal] = field(default_factory=list) + resource_prices: list[LearningResourcePrice] = field(default_factory=list) availability: str = None location: str = None diff --git a/learning_resources/etl/loaders.py b/learning_resources/etl/loaders.py index 238ef8b40b..1a80590a13 100644 --- a/learning_resources/etl/loaders.py +++ b/learning_resources/etl/loaders.py @@ -32,6 +32,7 @@ LearningResourceInstructor, LearningResourceOfferor, LearningResourcePlatform, + LearningResourcePrice, LearningResourceRelationship, LearningResourceRun, LearningResourceTopic, @@ -145,11 +146,17 @@ def load_run_dependent_values( if resource.certification and best_run and best_run.prices else [] ) + resource.resource_prices.set( + best_run.resource_prices.all() + if resource.certification and best_run and best_run.resource_prices + else [] + ) resource.location = best_run.location resource.save() return ResourceNextRunConfig( next_start_date=resource.next_start_date, prices=resource.prices, + resource_prices=resource.resource_prices.all(), availability=resource.availability, location=resource.location, ) @@ -182,6 +189,23 @@ def load_instructors( return instructors +def load_prices( + run: LearningResourceRun, prices_data: list[dict] +) -> list[LearningResourcePrice]: + """Load the prices for a resource run into the database""" + prices = [] + for price in prices_data: + lr_price, _ = LearningResourcePrice.objects.get_or_create( + amount=price["amount"], + currency=price["currency"], + ) + prices.append(lr_price) + + run.resource_prices.set(prices) + run.save() + return prices + + def load_image(resource: LearningResource, image_data: dict) -> LearningResourceImage: """Load the image for a resource into the database""" if image_data: @@ -253,14 +277,13 @@ def load_run( status = run_data.pop("status", None) instructors_data = run_data.pop("instructors", []) + resource_prices = run_data.get("prices", []) + run_data["prices"] = sorted({price["amount"] for price in resource_prices}) + if status == RunStatus.archived.value or learning_resource.certification is False: # Archived runs or runs of resources w/out certificates should not have prices run_data["prices"] = [] - else: - # Make sure any prices are unique and sorted in ascending order - run_data["prices"] = sorted( - set(run_data.get("prices", [])), key=lambda x: float(x) - ) + resource_prices = [] with transaction.atomic(): ( @@ -273,6 +296,7 @@ def load_run( ) load_instructors(learning_resource_run, instructors_data) + load_prices(learning_resource_run, resource_prices) load_image(learning_resource_run, image_data) return learning_resource_run diff --git a/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py index 1aabe6e9e9..c9361a1852 100644 --- a/learning_resources/etl/loaders_test.py +++ b/learning_resources/etl/loaders_test.py @@ -11,6 +11,7 @@ from django.utils import timezone from learning_resources.constants import ( + CURRENCY_USD, Availability, LearningResourceDelivery, LearningResourceRelationTypes, @@ -58,6 +59,7 @@ LearningResourceInstructorFactory, LearningResourceOfferorFactory, LearningResourcePlatformFactory, + LearningResourcePriceFactory, LearningResourceRunFactory, LearningResourceTopicFactory, PodcastEpisodeFactory, @@ -91,6 +93,7 @@ "content_tags", "resources", "delivery", + "resource_prices", ) @@ -375,14 +378,20 @@ def test_load_course( # noqa: PLR0913,PLR0912,PLR0915 "enrollment_start": old_run.enrollment_start, "start_date": old_run.start_date, "end_date": old_run.end_date, - "prices": [30.00, 120.00], + "prices": [ + {"amount": Decimal(30.00), "currency": CURRENCY_USD}, + {"amount": Decimal(120.00), "currency": CURRENCY_USD}, + ], }, { "run_id": run.run_id, "enrollment_start": run.enrollment_start, "start_date": start_date, "end_date": run.end_date, - "prices": [0.00, 49.00], + "prices": [ + {"amount": Decimal(0.00), "currency": CURRENCY_USD}, + {"amount": Decimal(49.00), "currency": CURRENCY_USD}, + ], }, ] props["runs"] = runs @@ -403,6 +412,11 @@ def test_load_course( # noqa: PLR0913,PLR0912,PLR0915 if is_run_published and result.certification else [] ) + assert [price.amount for price in result.resource_prices.all()] == ( + [Decimal(0.00), Decimal(49.00)] + if is_run_published and result.certification + else [] + ) if course_exists and ((not is_published or not is_run_published) or blocklisted): mock_upsert_tasks.deindex_learning_resource.assert_called_with( @@ -656,6 +670,7 @@ def test_load_run(run_exists, status, certification): if run_exists else LearningResourceRunFactory.build() ) + prices = [Decimal(70.00), Decimal(20.00)] props = model_to_dict( LearningResourceRunFactory.build( run_id=learning_resource_run.run_id, @@ -663,9 +678,11 @@ def test_load_run(run_exists, status, certification): ) ) props["status"] = status + props["prices"] = [{"amount": price, "currency": CURRENCY_USD} for price in prices] del props["id"] del props["learning_resource"] + del props["resource_prices"] assert LearningResourceRun.objects.count() == (1 if run_exists else 0) assert course.certification == certification @@ -677,13 +694,17 @@ def test_load_run(run_exists, status, certification): assert result.learning_resource == course assert isinstance(result, LearningResourceRun) - assert result.prices == ( [] if (status == RunStatus.archived.value or certification is False) - else sorted(props["prices"]) + else sorted(prices) + ) + + assert [price.amount for price in result.resource_prices.all()] == ( + [] + if (status == RunStatus.archived.value or certification is False) + else sorted(prices) ) - props.pop("prices") for key, value in props.items(): assert getattr(result, key) == value, f"Property {key} should equal {value}" @@ -1462,6 +1483,7 @@ def test_load_run_dependent_values(certification): published=True, availability=Availability.dated.name, prices=[Decimal("0.00"), Decimal("20.00")], + resource_prices=LearningResourcePriceFactory.create_batch(2), start_date=closest_date, location="Portland, ME", ) @@ -1470,12 +1492,18 @@ def test_load_run_dependent_values(certification): published=True, availability=Availability.dated.name, prices=[Decimal("0.00"), Decimal("50.00")], + resource_prices=LearningResourcePriceFactory.create_batch(2), start_date=furthest_date, location="Portland, OR", ) result = load_run_dependent_values(course) assert result.next_start_date == course.next_start_date == closest_date assert result.prices == course.prices == ([] if not certification else run.prices) + assert ( + list(result.resource_prices) + == list(course.resource_prices.all()) + == ([] if not certification else list(run.resource_prices.all())) + ) assert result.availability == course.availability == Availability.dated.name assert result.location == course.location == run.location diff --git a/learning_resources/etl/micromasters.py b/learning_resources/etl/micromasters.py index 6bebb30d35..48f0791d35 100644 --- a/learning_resources/etl/micromasters.py +++ b/learning_resources/etl/micromasters.py @@ -1,6 +1,7 @@ """MicroMasters ETL""" import logging +from decimal import Decimal import requests from django.conf import settings @@ -14,6 +15,7 @@ PlatformType, ) from learning_resources.etl.constants import COMMON_HEADERS, ETLSource +from learning_resources.etl.utils import transform_price from learning_resources.models import LearningResource, default_pace OFFERED_BY = {"code": OfferedBy.mitx.name} @@ -136,7 +138,9 @@ def transform(programs_data): {"full_name": instructor["name"]} for instructor in program["instructors"] ], - "prices": [program["total_price"]], + "prices": [ + transform_price(Decimal(program["total_price"])) + ], "start_date": program["start_date"] or program["enrollment_start"], "end_date": program["end_date"], diff --git a/learning_resources/etl/micromasters_test.py b/learning_resources/etl/micromasters_test.py index 343d61260c..8bdfdb68d3 100644 --- a/learning_resources/etl/micromasters_test.py +++ b/learning_resources/etl/micromasters_test.py @@ -1,9 +1,12 @@ """Tests for MicroMasters ETL functions""" +from decimal import Decimal + # pylint: disable=redefined-outer-name import pytest from learning_resources.constants import ( + CURRENCY_USD, Availability, CertificationType, Format, @@ -164,7 +167,9 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): {"full_name": "Dr. Doofenshmirtz"}, {"full_name": "Joey Jo Jo Shabadoo"}, ], - "prices": ["123.45"], + "prices": [ + {"amount": Decimal("123.45"), "currency": CURRENCY_USD} + ], "start_date": "2019-10-04T20:13:26.367297Z", "end_date": None, "enrollment_start": "2019-09-29T20:13:26.367297Z", diff --git a/learning_resources/etl/mitxonline.py b/learning_resources/etl/mitxonline.py index 296174d960..027b8c9708 100644 --- a/learning_resources/etl/mitxonline.py +++ b/learning_resources/etl/mitxonline.py @@ -4,6 +4,7 @@ import logging import re from datetime import UTC +from decimal import Decimal from urllib.parse import urljoin import requests @@ -24,6 +25,7 @@ generate_course_numbers_json, get_department_id_by_name, parse_certification, + transform_price, transform_topics, ) from main.utils import clean_data @@ -148,7 +150,7 @@ def extract_courses(): return [] -def parse_program_prices(program_data: dict) -> list[float]: +def parse_program_prices(program_data: dict) -> list[dict]: """Return a list of unique prices for a program""" prices = [program_data.get("current_price") or 0.00] price_string = parse_page_attribute(program_data, "price") @@ -159,7 +161,7 @@ def parse_program_prices(program_data: dict) -> list[float]: for price in re.findall(r"[\d\.,]+", price_string) ] ) - return sorted(set(prices)) + return [transform_price(Decimal(price)) for price in sorted(set(prices))] def parse_departments(departments_data: list[dict or str]) -> list[str]: @@ -218,19 +220,22 @@ def _transform_run(course_run: dict, course: dict) -> dict: "published": bool(course_run["is_enrollable"] and course["page"]["live"]), "description": clean_data(parse_page_attribute(course_run, "description")), "image": _transform_image(course_run), - "prices": sorted( - { - "0.00", - *[ - price - for price in [ - product.get("price") - for product in course_run.get("products", []) - ] - if price is not None - ], - } - ), + "prices": [ + transform_price(price) + for price in sorted( + { + Decimal(0.00), + *[ + Decimal(price) + for price in [ + product.get("price") + for product in course_run.get("products", []) + ] + if price is not None + ], + } + ) + ], "instructors": [ {"full_name": instructor["name"]} for instructor in parse_page_attribute(course, "instructors", is_list=True) diff --git a/learning_resources/etl/mitxonline_test.py b/learning_resources/etl/mitxonline_test.py index a7364d393c..5995261c57 100644 --- a/learning_resources/etl/mitxonline_test.py +++ b/learning_resources/etl/mitxonline_test.py @@ -4,12 +4,14 @@ # pylint: disable=redefined-outer-name from datetime import datetime +from decimal import Decimal from unittest.mock import ANY from urllib.parse import urljoin import pytest from learning_resources.constants import ( + CURRENCY_USD, CertificationType, Format, LearningResourceType, @@ -234,19 +236,23 @@ def test_mitxonline_transform_programs( and course_data["page"]["live"] ), "prices": sorted( - { - "0.00", - *[ - price - for price in [ - product.get("price") - for product in course_run_data.get( - "products", [] - ) - ] - if price is not None - ], - } + [ + {"amount": Decimal(i), "currency": CURRENCY_USD} + for i in { + 0.00, + *[ + price + for price in [ + product.get("price") + for product in course_run_data.get( + "products", [] + ) + ] + if price is not None + ], + } + ], + key=lambda x: x["amount"], ), "instructors": [ {"full_name": instructor["name"]} @@ -367,17 +373,23 @@ def test_mitxonline_transform_courses(settings, mock_mitxonline_courses_data): course_run_data["is_enrollable"] and course_data["page"]["live"] ), "prices": sorted( - { - "0.00", - *[ - price - for price in [ - product.get("price") - for product in course_run_data.get("products", []) - ] - if price is not None - ], - } + [ + {"amount": Decimal(i), "currency": CURRENCY_USD} + for i in { + 0.00, + *[ + price + for price in [ + product.get("price") + for product in course_run_data.get( + "products", [] + ) + ] + if price is not None + ], + } + ], + key=lambda x: x["amount"], ), "instructors": [ {"full_name": instructor["name"]} @@ -500,7 +512,8 @@ def test_parse_prices(current_price, page_price, expected): """Test that the prices are correctly parsed from the page data""" program_data = {"current_price": current_price, "page": {"price": page_price}} assert parse_program_prices(program_data) == sorted( - [float(price) for price in expected] + [{"amount": float(price), "currency": CURRENCY_USD} for price in expected], + key=lambda x: x["amount"], ) diff --git a/learning_resources/etl/oll.py b/learning_resources/etl/oll.py index 96bd8436b3..6bf62b41df 100644 --- a/learning_resources/etl/oll.py +++ b/learning_resources/etl/oll.py @@ -3,7 +3,6 @@ import logging from _csv import QUOTE_MINIMAL from csv import DictReader -from decimal import Decimal from io import StringIO from pathlib import Path @@ -130,7 +129,6 @@ def transform_run(course_data: dict) -> list[dict]: "published": course_data["published"] == "YES", "description": course_data["description"], "image": transform_image(course_data), - "prices": [Decimal(0.00)], "level": transform_levels([course_data["Level"]]) if course_data["Level"] else [], @@ -184,7 +182,6 @@ def transform_course(course_data: dict) -> dict: }, "runs": runs, "image": transform_image(course_data), - "prices": [Decimal(0.00)], "etl_source": ETLSource.oll.name, "availability": Availability.anytime.name, "pace": [Pace.self_paced.name], diff --git a/learning_resources/etl/oll_test.py b/learning_resources/etl/oll_test.py index 91e9e05c7d..e8c6121222 100644 --- a/learning_resources/etl/oll_test.py +++ b/learning_resources/etl/oll_test.py @@ -1,7 +1,6 @@ """Tests for the OLL ETL functions""" # pylint: disable=redefined-outer-name - import pytest from learning_resources.etl.oll import extract, transform @@ -63,7 +62,6 @@ def test_oll_transform(mocker, oll_course_data): "url": "https://openlearninglibrary.mit.edu/asset-v1:MITx+18.05r_10+2022_Summer+type@asset+block@mit18_05_s22_chp.jpg", "alt": "Introduction to Probability and Statistics", }, - "prices": [0.00], "level": ["undergraduate"], "instructors": [ {"full_name": "Jeremy Orloff"}, @@ -81,7 +79,6 @@ def test_oll_transform(mocker, oll_course_data): "url": "https://openlearninglibrary.mit.edu/asset-v1:MITx+18.05r_10+2022_Summer+type@asset+block@mit18_05_s22_chp.jpg", "alt": "Introduction to Probability and Statistics", }, - "prices": [0.00], "etl_source": "oll", "availability": "anytime", "pace": ["self_paced"], @@ -119,7 +116,6 @@ def test_oll_transform(mocker, oll_course_data): "url": "https://openlearninglibrary.mit.edu/asset-v1:MITx+0.502x+1T2019+type@asset+block@course_image.png", "alt": "Competency-Based Education", }, - "prices": [0.00], "level": ["undergraduate"], "instructors": [ {"full_name": "Justin Reich"}, @@ -137,7 +133,6 @@ def test_oll_transform(mocker, oll_course_data): "url": "https://openlearninglibrary.mit.edu/asset-v1:MITx+0.502x+1T2019+type@asset+block@course_image.png", "alt": "Competency-Based Education", }, - "prices": [0.00], "etl_source": "oll", "availability": "anytime", "pace": ["self_paced"], diff --git a/learning_resources/etl/openedx.py b/learning_resources/etl/openedx.py index 18a1ce118c..6e245c96ff 100644 --- a/learning_resources/etl/openedx.py +++ b/learning_resources/etl/openedx.py @@ -16,6 +16,7 @@ from toolz import compose from learning_resources.constants import ( + CURRENCY_USD, Availability, CertificationType, Format, @@ -30,6 +31,7 @@ generate_course_numbers_json, parse_certification, transform_levels, + transform_price, without_none, ) from learning_resources.models import LearningResource @@ -298,7 +300,7 @@ def _parse_course_dates(program, date_field): return dates -def _sum_course_prices(program: dict) -> Decimal: +def _sum_course_prices(program: dict) -> dict: """ Sum all the course run price values for the program @@ -306,7 +308,7 @@ def _sum_course_prices(program: dict) -> Decimal: program (dict): the program data Returns: - Decimal: the sum of all the course prices + Decimal, str: the sum of all the course prices and the currency code """ def _get_course_price(course): @@ -321,10 +323,17 @@ def _get_course_price(course): for seat in run["seats"] if seat["price"] != "0.00" ] - ) - return Decimal(0.00) + ), run.get("seats", [{}])[0].get("currency", CURRENCY_USD) + return Decimal(0.00), CURRENCY_USD - return sum(_get_course_price(course) for course in program.get("courses", [])) + prices_currencies = [ + _get_course_price(course) for course in program.get("courses", []) + ] + total, currency = ( + Decimal(sum(price_currency[0] for price_currency in prices_currencies)), + (prices_currencies[0][1] or CURRENCY_USD), + ) + return transform_price(total, currency) def _transform_course_run(config, course_run, course_last_modified, marketing_url): @@ -362,9 +371,19 @@ def _transform_course_run(config, course_run, course_last_modified, marketing_ur "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( - {"0.00", *[seat.get("price") for seat in course_run.get("seats", [])]} - ), + "prices": [ + transform_price(price, currency) + for (price, currency) in sorted( + { + (Decimal(0.00), CURRENCY_USD), + *[ + (Decimal(seat.get("price")), seat.get("currency")) + for seat in course_run.get("seats", []) + ], + }, + key=lambda x: x[0], + ) + ], "instructors": [ { "first_name": person.get("given_name"), diff --git a/learning_resources/etl/openedx_test.py b/learning_resources/etl/openedx_test.py index 3ae662fbe7..3c9831e0b4 100644 --- a/learning_resources/etl/openedx_test.py +++ b/learning_resources/etl/openedx_test.py @@ -8,6 +8,7 @@ import pytest from learning_resources.constants import ( + CURRENCY_USD, Availability, CertificationType, Format, @@ -249,7 +250,10 @@ def test_transform_course( # noqa: PLR0913 "languages": ["en-us"], "last_modified": any_instance_of(datetime), "level": ["intermediate"], - "prices": ["0.00", "150.00"], + "prices": [ + {"amount": Decimal("0.00"), "currency": CURRENCY_USD}, + {"amount": Decimal("150.00"), "currency": CURRENCY_USD}, + ], "semester": "Spring", "description": "short_description", "start_date": expected_dt, @@ -458,7 +462,7 @@ def test_transform_program( ], "last_modified": any_instance_of(datetime), "level": [], - "prices": [Decimal("567.00")], + "prices": [{"amount": Decimal("567.00"), "currency": CURRENCY_USD}], "title": extracted[0]["title"], "url": extracted[0]["marketing_url"], "published": True, diff --git a/learning_resources/etl/prolearn.py b/learning_resources/etl/prolearn.py index 022aa6cc62..e8a82435d7 100644 --- a/learning_resources/etl/prolearn.py +++ b/learning_resources/etl/prolearn.py @@ -9,9 +9,18 @@ import requests from django.conf import settings -from learning_resources.constants import Availability, CertificationType, Format, Pace +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.etl.utils import ( + transform_delivery, + transform_price, + transform_topics, +) from learning_resources.models import LearningResourceOfferor, LearningResourcePlatform from main.utils import clean_data, now_in_utc @@ -109,7 +118,7 @@ def parse_date(num) -> datetime: return None -def parse_price(document: dict) -> Decimal: +def parse_price(document: dict) -> list[dict]: """ Get a Decimal value for a course/program price @@ -117,14 +126,14 @@ def parse_price(document: dict) -> Decimal: document: course or program data Returns: - Decimal: price of the course/program + list of dict: price/currency of the course/program """ price_str = ( re.sub(r"[^\d.]", "", document["field_price"]) if document.get("field_price") is not None else "" ) - return [round(Decimal(price_str), 2)] if price_str else [] + return [transform_price(round(Decimal(price_str), 2))] if price_str else [] def parse_topic(document: dict, offeror_code: str) -> list[dict]: diff --git a/learning_resources/etl/prolearn_test.py b/learning_resources/etl/prolearn_test.py index dd216a731e..d7016fb8c6 100644 --- a/learning_resources/etl/prolearn_test.py +++ b/learning_resources/etl/prolearn_test.py @@ -9,6 +9,7 @@ from data_fixtures.utils import upsert_topic_data_file from learning_resources.constants import ( + CURRENCY_USD, Availability, CertificationType, Format, @@ -292,18 +293,20 @@ def test_parse_date(date_int, expected_dt): @pytest.mark.parametrize( - ("price_str", "price_list"), + ("price_str", "expected_price"), [ - ["$5,342", [round(Decimal(5342), 2)]], # noqa: PT007 - ["5.34", [round(Decimal(5.34), 2)]], # noqa: PT007 - [None, []], # noqa: PT007 - ["", []], # noqa: PT007 + ["$5,342", round(Decimal(5342), 2)], # noqa: PT007 + ["5.34", round(Decimal(5.34), 2)], # noqa: PT007 + [None, None], # noqa: PT007 + ["", None], # noqa: PT007 ], ) -def test_parse_price(price_str, price_list): +def test_parse_price(price_str, expected_price): """Price string should be parsed into correct Decimal list""" document = {"field_price": price_str} - assert parse_price(document) == price_list + assert parse_price(document) == ( + [{"amount": expected_price, "currency": CURRENCY_USD}] if expected_price else [] + ) @pytest.mark.parametrize( diff --git a/learning_resources/etl/sloan.py b/learning_resources/etl/sloan.py index deb90008ce..82c3aa3284 100644 --- a/learning_resources/etl/sloan.py +++ b/learning_resources/etl/sloan.py @@ -2,6 +2,7 @@ import logging from datetime import UTC +from decimal import Decimal from urllib.parse import urljoin from zoneinfo import ZoneInfo @@ -10,6 +11,7 @@ from django.conf import settings from learning_resources.constants import ( + CURRENCY_USD, Availability, CertificationType, Format, @@ -21,6 +23,7 @@ from learning_resources.etl.constants import ETLSource from learning_resources.etl.utils import ( transform_delivery, + transform_price, transform_topics, ) from learning_resources.models import default_format @@ -243,7 +246,11 @@ def transform_run(run_data, course_data): "delivery": transform_delivery(run_data["Delivery"]), "availability": parse_availability(run_data), "published": True, - "prices": [run_data["Price"]], + "prices": [ + transform_price( + Decimal(run_data["Price"]), run_data["Currency"] or CURRENCY_USD + ) + ], "instructors": [{"full_name": name.strip()} for name in faculty_names], "pace": [parse_pace(run_data)], "format": parse_format(run_data), diff --git a/learning_resources/etl/sloan_test.py b/learning_resources/etl/sloan_test.py index b22a09ef7d..ee0510c9e6 100644 --- a/learning_resources/etl/sloan_test.py +++ b/learning_resources/etl/sloan_test.py @@ -1,6 +1,7 @@ """Tests for prolearn etl functions""" import json +from decimal import Decimal from urllib.parse import urljoin import pytest @@ -127,7 +128,7 @@ def test_transform_run( "delivery": transform_delivery(run_data["Delivery"]), "availability": parse_availability(run_data), "published": True, - "prices": [run_data["Price"]], + "prices": [{"amount": Decimal(run_data["Price"]), "currency": "USD"}], "instructors": [{"full_name": name.strip()} for name in faculty_names], "pace": [Pace.instructor_paced.name], "format": [Format.synchronous.name], diff --git a/learning_resources/etl/utils.py b/learning_resources/etl/utils.py index 8db5a08d20..819f529646 100644 --- a/learning_resources/etl/utils.py +++ b/learning_resources/etl/utils.py @@ -10,6 +10,7 @@ from collections import Counter from collections.abc import Generator from datetime import UTC, datetime +from decimal import Decimal from hashlib import md5 from pathlib import Path from subprocess import check_call @@ -21,6 +22,7 @@ from django.conf import settings from django.utils.dateparse import parse_duration from django.utils.text import slugify +from pycountry import currencies from tika import parser as tika_parser from xbundle import XBundle @@ -29,6 +31,7 @@ CONTENT_TYPE_PDF, CONTENT_TYPE_VERTICAL, CONTENT_TYPE_VIDEO, + CURRENCY_USD, DEPARTMENTS, VALID_TEXT_FILE_TYPES, LearningResourceDelivery, @@ -746,3 +749,21 @@ def iso8601_duration(duration_str: str) -> str or None: second_duration = f"{int(seconds)}S" if seconds else "" return f"PT{hour_duration}{minute_duration}{second_duration}" return "PT0S" + + +def transform_price(amount: Decimal, currency: str = CURRENCY_USD) -> dict: + """ + Transform the price data into a dict + + Args: + amount (Decimal): The price amount + currency (str): The currency code + + Returns: + dict: The price data + """ + return { + "amount": amount, + # Use pycountry.currencies to ensure the code is valid, default to USD + "currency": currency if currencies.get(alpha_3=currency) else CURRENCY_USD, + } diff --git a/learning_resources/etl/utils_test.py b/learning_resources/etl/utils_test.py index 94ec8f8500..2ee4ff1724 100644 --- a/learning_resources/etl/utils_test.py +++ b/learning_resources/etl/utils_test.py @@ -2,6 +2,7 @@ import datetime import pathlib +from decimal import Decimal from random import randrange from subprocess import check_call from tempfile import TemporaryDirectory @@ -13,6 +14,7 @@ from learning_resources.constants import ( CONTENT_TYPE_FILE, CONTENT_TYPE_VERTICAL, + CURRENCY_USD, LearningResourceDelivery, LearningResourceType, OfferedBy, @@ -482,3 +484,19 @@ def test_parse_duration(mocker, duration_str, expected): mock_warn = mocker.patch("learning_resources.etl.utils.log.warning") assert utils.iso8601_duration(duration_str) == expected assert mock_warn.call_count == (1 if duration_str and expected is None else 0) + + +@pytest.mark.parametrize( + ("amount", "currency", "valid_currency"), + [ + (410.52, "EUR", True), + (100.00, "USD", True), + (200.00, "YYY", False), + ], +) +def test_transform_price(amount, currency, valid_currency): + """Test that transform_price returns the expected price""" + assert utils.transform_price(Decimal(amount), currency) == { + "amount": amount, + "currency": currency if valid_currency else CURRENCY_USD, + } diff --git a/learning_resources/etl/xpro.py b/learning_resources/etl/xpro.py index c6f3f4b225..b0b6907705 100644 --- a/learning_resources/etl/xpro.py +++ b/learning_resources/etl/xpro.py @@ -3,6 +3,7 @@ import copy import logging from datetime import UTC +from decimal import Decimal import requests from dateutil.parser import parse @@ -20,6 +21,7 @@ from learning_resources.etl.utils import ( generate_course_numbers_json, transform_delivery, + transform_price, transform_topics, ) from main.utils import clean_data @@ -111,7 +113,9 @@ def _transform_run(course_run: dict, course: dict) -> dict: "enrollment_end": _parse_datetime(course_run["enrollment_end"]), "published": bool(course_run["current_price"]), "prices": ( - [course_run["current_price"]] if course_run.get("current_price") else [] + [transform_price(Decimal(course_run["current_price"]))] + if course_run.get("current_price") + else [] ), "instructors": [ {"full_name": instructor["name"]} @@ -204,7 +208,7 @@ def transform_programs(programs): "runs": [ { "prices": ( - [program["current_price"]] + [transform_price(Decimal(program["current_price"]))] if program.get("current_price", None) else [] ), diff --git a/learning_resources/etl/xpro_test.py b/learning_resources/etl/xpro_test.py index 7acb9e8226..6cb6f29bea 100644 --- a/learning_resources/etl/xpro_test.py +++ b/learning_resources/etl/xpro_test.py @@ -8,6 +8,7 @@ import pytest from learning_resources.constants import ( + CURRENCY_USD, Availability, CertificationType, Format, @@ -124,7 +125,12 @@ def test_xpro_transform_programs(mock_xpro_programs_data): "end_date": any_instance_of(datetime, type(None)), "enrollment_start": any_instance_of(datetime, type(None)), "prices": ( - [program_data["current_price"]] + [ + { + "amount": program_data["current_price"], + "currency": CURRENCY_USD, + } + ] if program_data["current_price"] else [] ), @@ -171,7 +177,12 @@ def test_xpro_transform_programs(mock_xpro_programs_data): "enrollment_end": any_instance_of(datetime, type(None)), "published": bool(course_run_data["current_price"]), "prices": ( - [course_run_data["current_price"]] + [ + { + "amount": course_run_data["current_price"], + "currency": CURRENCY_USD, + } + ] if course_run_data["current_price"] else [] ), @@ -245,7 +256,12 @@ def test_xpro_transform_courses(mock_xpro_courses_data): "enrollment_end": any_instance_of(datetime, type(None)), "published": bool(course_run_data["current_price"]), "prices": ( - [course_run_data["current_price"]] + [ + { + "amount": course_run_data["current_price"], + "currency": CURRENCY_USD, + } + ] if course_run_data["current_price"] else [] ), diff --git a/learning_resources/factories.py b/learning_resources/factories.py index 5252d2894e..2fcc403503 100644 --- a/learning_resources/factories.py +++ b/learning_resources/factories.py @@ -8,10 +8,11 @@ import factory from factory import Faker from factory.django import DjangoModelFactory -from factory.fuzzy import FuzzyChoice, FuzzyText +from factory.fuzzy import FuzzyChoice, FuzzyDecimal, FuzzyText from learning_resources import constants, models from learning_resources.constants import ( + CURRENCY_USD, DEPARTMENTS, Availability, LearningResourceDelivery, @@ -470,6 +471,16 @@ class Params: has_certification = factory.Trait(learning_resource__certification=True) +class LearningResourcePriceFactory(DjangoModelFactory): + """Factory for LearningResourcePrice""" + + amount = FuzzyDecimal(100, 200) + currency = CURRENCY_USD + + class Meta: + model = models.LearningResourcePrice + + class LearningResourceRunFactory(DjangoModelFactory): """Factory for LearningResourceRuns""" @@ -513,6 +524,19 @@ class LearningResourceRunFactory(DjangoModelFactory): ] ) + @factory.post_generation + def resource_prices(self, create, extracted, **kwargs): # noqa: ARG002 + """Create resource prices for course""" + if not create: + return + + if extracted is None: + extracted = LearningResourcePriceFactory.create_batch( + random.randint(1, 3) # noqa: S311 + ) + + self.resource_prices.set(extracted) + @factory.post_generation def instructors(self, create, extracted, **kwargs): # noqa: ARG002 """Create instructors for course""" @@ -531,7 +555,7 @@ class Meta: skip_postgeneration_save = True class Params: - no_prices = factory.Trait(prices=[]) + no_prices = factory.Trait(resource_prices=[], prices=[]) no_instructors = factory.Trait(instructors=[]) is_unpublished = factory.Trait(learning_resource__published=False) diff --git a/learning_resources/filters.py b/learning_resources/filters.py index 0fad5eb53e..bd23cd4929 100644 --- a/learning_resources/filters.py +++ b/learning_resources/filters.py @@ -131,9 +131,8 @@ def filter_free(self, queryset, _, value): """Free cost filter for learning resources""" free_filter = ( Q(runs__isnull=True) - | Q(runs__prices__isnull=True) - | Q(runs__prices=[]) - | Q(runs__prices__contains=[Decimal(0.00)]) + | Q(runs__resource_prices__isnull=True) + | Q(runs__resource_prices__amount=Decimal(0.00)) ) & Q(professional=False) if value: # Free resources diff --git a/learning_resources/filters_test.py b/learning_resources/filters_test.py index a71b880106..3f5aa10f3c 100644 --- a/learning_resources/filters_test.py +++ b/learning_resources/filters_test.py @@ -1,5 +1,6 @@ """Tests for learning_resources Filters""" +from decimal import Decimal from types import SimpleNamespace import pytest @@ -22,6 +23,7 @@ LearningResourceFactory, LearningResourceOfferorFactory, LearningResourcePlatformFactory, + LearningResourcePriceFactory, LearningResourceRunFactory, PodcastEpisodeFactory, PodcastFactory, @@ -233,24 +235,38 @@ def test_learning_resource_filter_free(client): free_course = LearningResourceFactory.create( is_course=True, runs=[], professional=False ) - LearningResourceRunFactory.create(learning_resource=free_course, prices=[0.00]) + LearningResourceRunFactory.create( + learning_resource=free_course + ).resource_prices.set([LearningResourcePriceFactory.create(amount=Decimal(0.00))]) paid_course = LearningResourceFactory.create(is_course=True, runs=[]) LearningResourceRunFactory.create( - learning_resource=paid_course, prices=[50.00, 100.00] + learning_resource=paid_course + ).resource_prices.set( + [ + LearningResourcePriceFactory.create(amount=Decimal(50.00)), + LearningResourcePriceFactory.create(amount=Decimal(100.00)), + ] ) free2pay_course = LearningResourceFactory( is_course=True, runs=[], professional=False ) LearningResourceRunFactory.create( - learning_resource=free2pay_course, prices=[0.00, 100.00] + learning_resource=free2pay_course + ).resource_prices.set( + [ + LearningResourcePriceFactory.create(amount=Decimal(0.00)), + LearningResourcePriceFactory.create(amount=Decimal(100.00)), + ] ) priceless_pro_course = LearningResourceFactory( is_course=True, runs=[], professional=True ) - LearningResourceRunFactory.create(learning_resource=priceless_pro_course, prices=[]) + LearningResourceRunFactory.create( + learning_resource=priceless_pro_course, no_prices=True + ) always_free_podcast_episode = LearningResourceFactory.create( is_podcast_episode=True, professional=False diff --git a/learning_resources/migrations/0071_learningresourceprice.py b/learning_resources/migrations/0071_learningresourceprice.py new file mode 100644 index 0000000000..24e854cbcf --- /dev/null +++ b/learning_resources/migrations/0071_learningresourceprice.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.16 on 2024-10-21 17:33 + +from django.db import migrations, models + +from learning_resources.constants import CURRENCY_USD + + +def migrate_price_values(apps, schema_editor): + """ + Convert prices in prices field to LearningResourcePrices. + Assumption is that all current prices are in USD. + """ + LearningResource = apps.get_model("learning_resources", "LearningResource") + LearningResourcePrice = apps.get_model( + "learning_resources", "LearningResourcePrice" + ) + + for resource in LearningResource.objects.exclude(prices=[]): + for price in resource.prices: + resource_price, _ = LearningResourcePrice.objects.get_or_create( + amount=price, currency=CURRENCY_USD + ) + resource.resource_prices.add(resource_price) + for run in resource.runs.exclude(prices=[]).exclude(prices__isnull=True): + for price in run.prices: + resource_price, _ = LearningResourcePrice.objects.get_or_create( + amount=price, currency=CURRENCY_USD + ) + run.resource_prices.add(resource_price) + + +class Migration(migrations.Migration): + dependencies = [ + ("learning_resources", "0070_learningresource_location"), + ] + + operations = [ + migrations.CreateModel( + name="LearningResourcePrice", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("amount", models.DecimalField(decimal_places=2, max_digits=12)), + ("currency", models.CharField(max_length=3)), + ], + options={ + "abstract": False, + "ordering": ["amount"], + }, + ), + migrations.AddField( + model_name="learningresourcerun", + name="resource_prices", + field=models.ManyToManyField( + blank=True, + to="learning_resources.learningresourceprice", + ), + ), + migrations.AddField( + model_name="learningresource", + name="resource_prices", + field=models.ManyToManyField( + blank=True, + to="learning_resources.learningresourceprice", + ), + ), + migrations.RunPython( + migrate_price_values, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/learning_resources/models.py b/learning_resources/models.py index b5f11e2981..e574529439 100644 --- a/learning_resources/models.py +++ b/learning_resources/models.py @@ -296,6 +296,19 @@ def __str__(self): return self.name +class LearningResourcePrice(TimestampedModel): + """Represents the price of a learning resource""" + + amount = models.DecimalField(max_digits=12, decimal_places=2) + currency = models.CharField(max_length=3) + + def __str__(self): + return f"{self.amount} {self.currency}" + + class Meta: + ordering = ["amount"] + + class LearningResourceInstructor(TimestampedModel): """ Instructors for learning resources @@ -323,6 +336,7 @@ def for_serialization(self, *, user: User | None = None): "topics", queryset=LearningResourceTopic.objects.for_serialization(), ), + Prefetch("resource_prices"), Prefetch( "offered_by", queryset=LearningResourceOfferor.objects.for_serialization(), @@ -425,6 +439,7 @@ class LearningResource(TimestampedModel): prices = ArrayField( models.DecimalField(decimal_places=2, max_digits=12), default=list ) + resource_prices = models.ManyToManyField(LearningResourcePrice, blank=True) availability = models.CharField( # noqa: DJ001 max_length=24, null=True, @@ -541,7 +556,9 @@ class LearningResourceRunQuerySet(TimestampedModelQuerySet): def for_serialization(self): """QuerySet for serialization""" - return self.select_related("image").prefetch_related("instructors") + return self.select_related("image").prefetch_related( + "instructors", "resource_prices" + ) class LearningResourceRun(TimestampedModel): @@ -581,6 +598,7 @@ class LearningResourceRun(TimestampedModel): prices = ArrayField( models.DecimalField(decimal_places=2, max_digits=12), null=True, blank=True ) + resource_prices = models.ManyToManyField(LearningResourcePrice, blank=True) checksum = models.CharField(max_length=32, null=True, blank=True) # noqa: DJ001 delivery = ArrayField( models.CharField( diff --git a/learning_resources/models_test.py b/learning_resources/models_test.py index 0bbe65e7f1..44cb2e6d85 100644 --- a/learning_resources/models_test.py +++ b/learning_resources/models_test.py @@ -23,12 +23,12 @@ def test_program_creation(): run = program.runs.first() assert run.start_date is not None assert run.image.url is not None - assert len(run.prices) > 0 + assert len(run.resource_prices.all()) > 0 assert run.instructors.count() > 0 assert resource.topics.count() > 0 assert resource.offered_by is not None assert resource.runs.count() == program.runs.count() - assert resource.prices == [] + assert len(resource.resource_prices.all()) == 0 def test_course_creation(): @@ -38,14 +38,14 @@ def test_course_creation(): assert resource.resource_type == LearningResourceType.course.name assert resource.title is not None assert resource.image.url is not None - assert 0 <= len(resource.prices) <= 3 + assert 0 <= len(resource.resource_prices.all()) <= 3 assert resource.course == course run = resource.runs.first() assert run.start_date is not None assert run.image.url is not None - assert len(run.prices) > 0 + assert len(run.resource_prices.all()) > 0 assert run.instructors.count() > 0 assert resource.topics.count() > 0 assert resource.offered_by is not None assert resource.runs.count() == course.runs.count() - assert resource.prices == [] + assert len(resource.resource_prices.all()) == 0 diff --git a/learning_resources/serializers.py b/learning_resources/serializers.py index 401f4f52d2..7424951892 100644 --- a/learning_resources/serializers.py +++ b/learning_resources/serializers.py @@ -37,6 +37,16 @@ class Meta: exclude = COMMON_IGNORED_FIELDS +class LearningResourcePriceSerializer(serializers.ModelSerializer): + """ + Serializer for LearningResourcePrice model + """ + + class Meta: + model = models.LearningResourcePrice + exclude = "id", *COMMON_IGNORED_FIELDS + + class LearningResourceTopicSerializer(serializers.ModelSerializer): """ Serializer for LearningResourceTopic model @@ -282,6 +292,7 @@ class LearningResourceRunSerializer(serializers.ModelSerializer): ) format = serializers.ListField(child=FormatSerializer(), read_only=True) pace = serializers.ListField(child=PaceSerializer(), read_only=True) + resource_prices = LearningResourcePriceSerializer(read_only=True, many=True) class Meta: model = models.LearningResourceRun @@ -438,6 +449,7 @@ class LearningResourceBaseSerializer(serializers.ModelSerializer, WriteableTopic child=serializers.DecimalField(max_digits=12, decimal_places=2), read_only=True, ) + resource_prices = LearningResourcePriceSerializer(read_only=True, many=True) runs = LearningResourceRunSerializer(read_only=True, many=True, allow_null=True) image = serializers.SerializerMethodField() learning_path_parents = MicroLearningPathRelationshipSerializer( @@ -469,7 +481,7 @@ def get_free(self, instance) -> bool: LearningResourceType.course.name, LearningResourceType.program.name, ]: - prices = instance.prices + prices = [price.amount for price in instance.resource_prices.all()] return not instance.professional and ( Decimal(0.00) in prices or not prices or prices == [] ) @@ -500,6 +512,7 @@ class Meta: read_only_fields = [ "free", "prices", + "resource_prices", "resource_category", "certification", "certification_type", diff --git a/learning_resources/serializers_test.py b/learning_resources/serializers_test.py index 4c49eee142..345f08a5f6 100644 --- a/learning_resources/serializers_test.py +++ b/learning_resources/serializers_test.py @@ -1,5 +1,7 @@ """Tests for learning_resources serializers""" +from decimal import Decimal + import pytest from channels.factories import ( @@ -10,6 +12,7 @@ from channels.models import Channel from learning_resources import factories, serializers, utils from learning_resources.constants import ( + CURRENCY_USD, LEARNING_MATERIAL_RESOURCE_CATEGORY, CertificationType, Format, @@ -209,6 +212,13 @@ def test_learning_resource_serializer( # noqa: PLR0913 instance=resource.platform ).data, "prices": sorted([f"{price:.2f}" for price in resource.prices]), + "resource_prices": sorted( + [ + {"amount": f"{price:.2f}", "currency": CURRENCY_USD} + for price in resource.resource_prices.all() + ], + key=lambda price: price.amount, + ), "professional": resource.professional, "position": None, "certification": resource.certification, @@ -222,7 +232,11 @@ def test_learning_resource_serializer( # noqa: PLR0913 or ( not resource.professional and ( - not resource.prices or all(price == 0 for price in resource.prices) + not resource.resource_prices.all() + or all( + price.amount == Decimal(0.00) + for price in resource.resource_prices.all() + ) ) ) ), @@ -366,6 +380,8 @@ def test_serialize_run_related_models(): serializer = serializers.LearningResourceRunSerializer(run) assert len(serializer.data["prices"]) > 0 assert str(serializer.data["prices"][0].replace(".", "")).isnumeric() + assert len(serializer.data["resource_prices"]) > 0 + assert serializer.data["resource_prices"][0]["amount"].replace(".", "").isnumeric() assert len(serializer.data["instructors"]) > 0 for attr in ("first_name", "last_name", "full_name"): assert attr in serializer.data["instructors"][0] diff --git a/learning_resources/views.py b/learning_resources/views.py index 88132473cb..3eee185926 100644 --- a/learning_resources/views.py +++ b/learning_resources/views.py @@ -421,8 +421,7 @@ class ResourceListItemsViewSet(NestedViewSetMixin, viewsets.ReadOnlyModelViewSet queryset = ( LearningResourceRelationship.objects.select_related("child") .prefetch_related( - "child__runs", - "child__runs__instructors", + "child__runs", "child__runs__instructors", "child__runs__resource_prices" ) .filter(child__published=True) ) diff --git a/learning_resources/views_test.py b/learning_resources/views_test.py index 4fd45106af..7b27720027 100644 --- a/learning_resources/views_test.py +++ b/learning_resources/views_test.py @@ -166,7 +166,7 @@ def test_program_endpoint(client, url, params): def test_program_detail_endpoint(client, django_assert_num_queries, url): """Test program endpoint""" program = ProgramFactory.create() - with django_assert_num_queries(14): + with django_assert_num_queries(16): resp = client.get(reverse(url, args=[program.learning_resource.id])) assert resp.data.get("title") == program.learning_resource.title assert resp.data.get("resource_type") == LearningResourceType.program.name @@ -211,7 +211,7 @@ def test_no_excess_queries(rf, user, mocker, django_assert_num_queries, course_c request = rf.get("/") request.user = user - with django_assert_num_queries(16): + with django_assert_num_queries(18): view = CourseViewSet(request=request) results = view.get_queryset().all() assert len(results) == course_count @@ -962,6 +962,7 @@ def test_featured_view_filter(client, offeror_featured_lists, parameter): else: for run in resource["runs"]: assert run["prices"] == [] + assert run["resource_prices"] == [] def test_similar_resources_endpoint_does_not_return_self(mocker, client): diff --git a/learning_resources_search/constants.py b/learning_resources_search/constants.py index 78757dad6d..796c48f5ea 100644 --- a/learning_resources_search/constants.py +++ b/learning_resources_search/constants.py @@ -285,6 +285,13 @@ class FilterConfig: }, }, "prices": {"type": "scaled_float", "scaling_factor": 100}, + "resource_prices": { + "type": "nested", + "properties": { + "amount": {"type": "scaled_float", "scaling_factor": 100}, + "currency": {"type": "keyword"}, + }, + }, "location": {"type": "keyword"}, }, }, @@ -295,6 +302,13 @@ class FilterConfig: "license_cc": {"type": "boolean"}, "continuing_ed_credits": {"type": "float"}, "location": {"type": "keyword"}, + "resource_prices": { + "type": "nested", + "properties": { + "amount": {"type": "scaled_float", "scaling_factor": 100}, + "currency": {"type": "keyword"}, + }, + }, } diff --git a/learning_resources_search/serializers_test.py b/learning_resources_search/serializers_test.py index 93721381d7..030678cf91 100644 --- a/learning_resources_search/serializers_test.py +++ b/learning_resources_search/serializers_test.py @@ -27,6 +27,7 @@ CourseFactory, LearningPathFactory, LearningPathRelationshipFactory, + LearningResourcePriceFactory, LearningResourceRunFactory, UserListFactory, UserListRelationshipFactory, @@ -89,6 +90,7 @@ "professional": True, "certification": "Certificates", "prices": [2250.0], + "resource_prices": [{"amount": 2250.0, "currency": "USD"}], "learning_path": None, "podcast": None, "podcast_episode": None, @@ -118,6 +120,7 @@ "enrollment_start": None, "enrollment_end": None, "prices": ["2250.00"], + "resource_prices": [{"amount": 2250.0, "currency": "USD"}], "checksum": None, } ], @@ -237,6 +240,7 @@ "professional": True, "certification": "Certificates", "prices": [2250.0], + "resource_prices": [{"amount": 2250.0, "currency": "USD"}], "learning_path": None, "podcast": None, "podcast_episode": None, @@ -263,6 +267,7 @@ "enrollment_start": None, "enrollment_end": None, "prices": ["2250.00"], + "resource_prices": [{"amount": 2250.0, "currency": "USD"}], "checksum": None, } ], @@ -367,6 +372,7 @@ "continuing_ed_credits": None, "license_cc": True, "prices": [0.00], + "resource_prices": [{"amount": 0.0, "currency": "USD"}], "last_modified": None, "runs": [], "course_feature": [], @@ -540,6 +546,7 @@ "continuing_ed_credits": None, "license_cc": True, "prices": [0.00], + "resource_prices": [{"amount": 0.0, "currency": "USD"}], "last_modified": None, "runs": [], "course_feature": [], @@ -661,9 +668,16 @@ def test_serialize_learning_resource_for_bulk( # noqa: PLR0913 completeness=completeness, ) - LearningResourceRunFactory.create( + run = LearningResourceRunFactory.create( learning_resource=resource, prices=[Decimal(0.00 if no_price else 1.00)] ) + run.resource_prices.set( + [ + LearningResourcePriceFactory.create( + amount=Decimal(0.00 if no_price else 1.00) + ) + ] + ) resource_age_date = datetime( 2009 if is_stale else 2024, 1, 1, 1, 1, 1, 0, tzinfo=UTC diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 56ffb548f8..7395a3e65a 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -8384,6 +8384,11 @@ components: format: decimal pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ readOnly: true + resource_prices: + type: array + items: + $ref: '#/components/schemas/LearningResourcePrice' + readOnly: true runs: type: array items: @@ -8551,6 +8556,7 @@ components: - professional - readable_id - resource_category + - resource_prices - resource_type - runs - title @@ -8880,6 +8886,11 @@ components: format: decimal pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ readOnly: true + resource_prices: + type: array + items: + $ref: '#/components/schemas/LearningResourcePrice' + readOnly: true runs: type: array items: @@ -9045,6 +9056,7 @@ components: - prices - readable_id - resource_category + - resource_prices - resource_type - runs - title @@ -9446,6 +9458,35 @@ components: maxLength: 256 required: - code + LearningResourcePrice: + type: object + description: Serializer for LearningResourcePrice model + properties: + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ + currency: + type: string + maxLength: 3 + required: + - amount + - currency + LearningResourcePriceRequest: + type: object + description: Serializer for LearningResourcePrice model + properties: + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ + currency: + type: string + minLength: 1 + maxLength: 3 + required: + - amount + - currency LearningResourceRelationship: type: object description: CRUD serializer for LearningResourceRelationship @@ -9575,6 +9616,11 @@ components: - code - name readOnly: true + resource_prices: + type: array + items: + $ref: '#/components/schemas/LearningResourcePrice' + readOnly: true run_id: type: string maxLength: 128 @@ -9660,6 +9706,7 @@ components: - instructors - level - pace + - resource_prices - run_id - title LearningResourceRunRequest: @@ -11006,6 +11053,11 @@ components: format: decimal pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ readOnly: true + resource_prices: + type: array + items: + $ref: '#/components/schemas/LearningResourcePrice' + readOnly: true runs: type: array items: @@ -11173,6 +11225,7 @@ components: - professional - readable_id - resource_category + - resource_prices - resource_type - runs - title @@ -11342,6 +11395,11 @@ components: format: decimal pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ readOnly: true + resource_prices: + type: array + items: + $ref: '#/components/schemas/LearningResourcePrice' + readOnly: true runs: type: array items: @@ -11509,6 +11567,7 @@ components: - professional - readable_id - resource_category + - resource_prices - resource_type - runs - title @@ -11816,6 +11875,11 @@ components: format: decimal pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ readOnly: true + resource_prices: + type: array + items: + $ref: '#/components/schemas/LearningResourcePrice' + readOnly: true runs: type: array items: @@ -11983,6 +12047,7 @@ components: - program - readable_id - resource_category + - resource_prices - resource_type - runs - title @@ -12424,6 +12489,11 @@ components: format: decimal pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ readOnly: true + resource_prices: + type: array + items: + $ref: '#/components/schemas/LearningResourcePrice' + readOnly: true runs: type: array items: @@ -12590,6 +12660,7 @@ components: - professional - readable_id - resource_category + - resource_prices - resource_type - runs - title @@ -12746,6 +12817,11 @@ components: format: decimal pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ readOnly: true + resource_prices: + type: array + items: + $ref: '#/components/schemas/LearningResourcePrice' + readOnly: true runs: type: array items: @@ -12912,6 +12988,7 @@ components: - professional - readable_id - resource_category + - resource_prices - resource_type - runs - title diff --git a/poetry.lock b/poetry.lock index a03036cab0..bc1679e91e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3113,6 +3113,17 @@ files = [ [package.dependencies] pyasn1 = ">=0.4.6,<0.7.0" +[[package]] +name = "pycountry" +version = "24.6.1" +description = "ISO country, subdivision, language, currency and script definitions and their translations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f"}, + {file = "pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221"}, +] + [[package]] name = "pycparser" version = "2.22" @@ -4912,4 +4923,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "3.12.6" -content-hash = "1018e271b445b94cc6a1822a54e84f51dfe595ccf6ca6f42c3b810d287d70241" +content-hash = "0fabc0a67d9edf71d548ceb67d51bc5c42fb8e77ac60c1aaad4e67a47cbbf6d3" diff --git a/pyproject.toml b/pyproject.toml index 4de3ca776b..8092065a05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ ruff = "0.7.1" dateparser = "^1.2.0" uwsgitop = "^0.12" pytest-lazy-fixtures = "^1.1.1" +pycountry = "^24.6.1" [tool.poetry.group.dev.dependencies]