diff --git a/common/djangoapps/course_modes/admin.py b/common/djangoapps/course_modes/admin.py index c6419d241bf4..e332974b8daf 100644 --- a/common/djangoapps/course_modes/admin.py +++ b/common/djangoapps/course_modes/admin.py @@ -188,6 +188,7 @@ class CourseModeAdmin(admin.ModelAdmin): 'mode_slug', 'mode_display_name', 'min_price', + 'price', 'currency', '_expiration_datetime', 'verification_deadline', @@ -202,6 +203,7 @@ class CourseModeAdmin(admin.ModelAdmin): 'course', 'mode_slug', 'min_price', + 'price', 'expiration_datetime_custom', 'sku', 'bulk_sku' diff --git a/common/djangoapps/course_modes/migrations/0014_auto_20210204_1659.py b/common/djangoapps/course_modes/migrations/0014_auto_20210204_1659.py new file mode 100644 index 000000000000..06bfbb47a061 --- /dev/null +++ b/common/djangoapps/course_modes/migrations/0014_auto_20210204_1659.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.18 on 2021-02-04 16:59 + +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_modes', '0013_auto_20200115_2022'), + ] + + operations = [ + migrations.AddField( + model_name='coursemode', + name='price', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=30, verbose_name='Price'), + ), + migrations.AddField( + model_name='coursemodesarchive', + name='price', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=30), + ), + migrations.AddField( + model_name='historicalcoursemode', + name='price', + field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=30, verbose_name='Price'), + ), + ] diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 3d2cb7791cd8..a3488b9ddd9a 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -5,6 +5,7 @@ from collections import defaultdict, namedtuple from datetime import timedelta +from decimal import Decimal import inspect # lint-amnesty, pylint: disable=unused-import import logging @@ -34,6 +35,7 @@ 'slug', 'name', 'min_price', + 'price', 'suggested_prices', 'currency', 'expiration_datetime', @@ -71,6 +73,9 @@ class CourseMode(models.Model): # really just the price of the course. min_price = models.IntegerField(default=0, verbose_name=_("Price")) + # This price is the same as min_price, but this is saved as a Decimal to accomodate courses that have a price with decimals (ie. 49.99) for propper display and consistency with ecommerce db, since min_price is currently an integer. This will eventually become the only price column in this model. + price = models.DecimalField(default=Decimal('0.00'), verbose_name=_("Price"), max_digits=30, decimal_places=2) + # the currency these prices are in, using lower case ISO currency codes currency = models.CharField(default=u"usd", max_length=8) @@ -146,6 +151,7 @@ class CourseMode(models.Model): settings.COURSE_MODE_DEFAULTS['slug'], settings.COURSE_MODE_DEFAULTS['name'], settings.COURSE_MODE_DEFAULTS['min_price'], + settings.COURSE_MODE_DEFAULTS['price'], settings.COURSE_MODE_DEFAULTS['suggested_prices'], settings.COURSE_MODE_DEFAULTS['currency'], settings.COURSE_MODE_DEFAULTS['expiration_datetime'], @@ -801,6 +807,7 @@ def to_tuple(self): self.mode_slug, self.mode_display_name, self.min_price, + self.price, self.suggested_prices, self.currency, self.expiration_datetime, @@ -907,6 +914,9 @@ class Meta(object): # minimum price in USD that we would like to charge for this mode of the course min_price = models.IntegerField(default=0) + # This price is the same as min_price, but this is saved as a Decimal to accomodate courses that have a price with decimals (ie. 49.99) for propper display and consistency with ecommerce db, since min_price is currently an integer. This will eventually become the only price column in this model. + price = models.DecimalField(default=Decimal('0.00'), max_digits=30, decimal_places=2) + # the suggested prices for this mode suggested_prices = models.CharField(max_length=255, blank=True, default=u'', validators=[validate_comma_separated_integer_list]) diff --git a/common/djangoapps/course_modes/rest_api/serializers.py b/common/djangoapps/course_modes/rest_api/serializers.py index f3c73657a2a3..ec257d02aa0b 100644 --- a/common/djangoapps/course_modes/rest_api/serializers.py +++ b/common/djangoapps/course_modes/rest_api/serializers.py @@ -22,6 +22,7 @@ class CourseModeSerializer(serializers.Serializer): mode_slug = serializers.CharField() mode_display_name = serializers.CharField() min_price = serializers.IntegerField(required=False) + price = serializers.DecimalField(required=False, max_digits=30, decimal_places=2) currency = serializers.CharField() expiration_datetime = serializers.DateTimeField(required=False) expiration_datetime_is_explicit = serializers.BooleanField(required=False) diff --git a/common/djangoapps/course_modes/rest_api/v1/tests/test_views.py b/common/djangoapps/course_modes/rest_api/v1/tests/test_views.py index ecbf18360e2a..42c7102ce4cc 100644 --- a/common/djangoapps/course_modes/rest_api/v1/tests/test_views.py +++ b/common/djangoapps/course_modes/rest_api/v1/tests/test_views.py @@ -7,6 +7,7 @@ import unittest import ddt +from decimal import Decimal from django.conf import settings from django.urls import reverse from opaque_keys.edx.keys import CourseKey @@ -134,6 +135,7 @@ def test_list_course_modes_happy_path(self): 'mode_slug': 'audit', 'mode_display_name': 'Audit', 'min_price': 0, + 'price': Decimal('0.00'), 'currency': 'usd', 'expiration_datetime': None, 'expiration_datetime_is_explicit': False, @@ -146,6 +148,7 @@ def test_list_course_modes_happy_path(self): 'mode_slug': 'verified', 'mode_display_name': 'Verified', 'min_price': 25, + 'price': Decimal('25.00'), 'currency': 'usd', 'expiration_datetime': None, 'expiration_datetime_is_explicit': False, @@ -286,6 +289,7 @@ def test_retrieve_course_mode_happy_path(self): 'mode_slug': 'audit', 'mode_display_name': 'Audit', 'min_price': 0, + 'price': Decimal('0.00'), 'currency': 'usd', 'expiration_datetime': None, 'expiration_datetime_is_explicit': False, diff --git a/common/djangoapps/course_modes/rest_api/v1/views.py b/common/djangoapps/course_modes/rest_api/v1/views.py index caeebb7097cf..ea0d7f391649 100644 --- a/common/djangoapps/course_modes/rest_api/v1/views.py +++ b/common/djangoapps/course_modes/rest_api/v1/views.py @@ -75,6 +75,7 @@ class CourseModesView(CourseModesMixin, ListCreateAPIView): * mode_display_name: The verbose name for the course mode. * min_price: The minimum price for which a user can enroll in this mode. + * price: The minimum price for which a user can enroll in this mode, as a decimal. This will eventually replace min_price. * currency: The currency of the listed prices. * expiration_datetime: The date and time after which users cannot enroll in the course in this mode (not required for POST). diff --git a/common/djangoapps/course_modes/tests/test_admin.py b/common/djangoapps/course_modes/tests/test_admin.py index c43745a58e32..ae3b0755abc0 100644 --- a/common/djangoapps/course_modes/tests/test_admin.py +++ b/common/djangoapps/course_modes/tests/test_admin.py @@ -51,6 +51,7 @@ def test_expiration_timezone(self): 'mode_slug': 'verified', 'mode_display_name': 'verified', 'min_price': 10, + "price": 10.00, 'currency': 'usd', '_expiration_datetime_0': expiration.date(), # due to django admin datetime widget passing as separate vals '_expiration_datetime_1': expiration.time(), @@ -208,6 +209,7 @@ def _admin_form(self, mode, upgrade_deadline=None): "_expiration_datetime": upgrade_deadline, "currency": "usd", "min_price": 10, + "price": 10.00, }, instance=course_mode) def _set_form_verification_deadline(self, form, deadline): diff --git a/common/djangoapps/course_modes/tests/test_models.py b/common/djangoapps/course_modes/tests/test_models.py index 50f1e56ee3b6..c47369d9f34e 100644 --- a/common/djangoapps/course_modes/tests/test_models.py +++ b/common/djangoapps/course_modes/tests/test_models.py @@ -8,6 +8,7 @@ import itertools from datetime import timedelta +from decimal import Decimal import ddt from django.core.exceptions import ValidationError @@ -51,6 +52,7 @@ def create_mode( mode_slug, mode_name, min_price=0, + price=Decimal('0.00'), suggested_prices='', currency='usd', expiration_datetime=None, @@ -63,6 +65,7 @@ def create_mode( mode_display_name=mode_name, mode_slug=mode_slug, min_price=min_price, + price=price, suggested_prices=suggested_prices, currency=currency, _expiration_datetime=expiration_datetime, @@ -70,7 +73,7 @@ def create_mode( def test_save(self): """ Verify currency is always lowercase. """ - cm, __ = self.create_mode('honor', 'honor', 0, '', 'USD') + cm, __ = self.create_mode('honor', 'honor', 0, 0.00, '', 'USD') self.assertEqual(cm.currency, 'usd') cm.currency = 'GHS' @@ -92,7 +95,7 @@ def test_nodes_for_course_single(self): self.create_mode('verified', 'Verified Certificate', 10) modes = CourseMode.modes_for_course(self.course_key) - mode = Mode(u'verified', u'Verified Certificate', 10, '', 'usd', None, None, None, None) + mode = Mode(u'verified', u'Verified Certificate', 10, 10.00, '', 'usd', None, None, None, None) self.assertEqual([mode], modes) modes_dict = CourseMode.modes_for_course_dict(self.course_key) @@ -104,11 +107,11 @@ def test_modes_for_course_multiple(self): """ Finding the modes when there's multiple modes """ - mode1 = Mode(u'honor', u'Honor Code Certificate', 0, '', 'usd', None, None, None, None) - mode2 = Mode(u'verified', u'Verified Certificate', 10, '', 'usd', None, None, None, None) + mode1 = Mode(u'honor', u'Honor Code Certificate', 0, 0.00, '', 'usd', None, None, None, None) + mode2 = Mode(u'verified', u'Verified Certificate', 10, 10.00, '', 'usd', None, None, None, None) set_modes = [mode1, mode2] for mode in set_modes: - self.create_mode(mode.slug, mode.name, mode.min_price, mode.suggested_prices) + self.create_mode(mode.slug, mode.name, mode.min_price, mode.price, mode.suggested_prices) modes = CourseMode.modes_for_course(self.course_key) self.assertEqual(modes, set_modes) @@ -124,12 +127,12 @@ def test_min_course_price_for_currency(self): self.assertEqual(0, CourseMode.min_course_price_for_currency(self.course_key, 'usd')) # create some modes - mode1 = Mode(u'honor', u'Honor Code Certificate', 10, '', 'usd', None, None, None, None) - mode2 = Mode(u'verified', u'Verified Certificate', 20, '', 'usd', None, None, None, None) - mode3 = Mode(u'honor', u'Honor Code Certificate', 80, '', 'cny', None, None, None, None) + mode1 = Mode(u'honor', u'Honor Code Certificate', 10, 10.00, '', 'usd', None, None, None, None) + mode2 = Mode(u'verified', u'Verified Certificate', 20, 20.00, '', 'usd', None, None, None, None) + mode3 = Mode(u'honor', u'Honor Code Certificate', 80, 80.00, '', 'cny', None, None, None, None) set_modes = [mode1, mode2, mode3] for mode in set_modes: - self.create_mode(mode.slug, mode.name, mode.min_price, mode.suggested_prices, mode.currency) + self.create_mode(mode.slug, mode.name, mode.min_price, mode.price, mode.suggested_prices, mode.currency) self.assertEqual(10, CourseMode.min_course_price_for_currency(self.course_key, 'usd')) self.assertEqual(80, CourseMode.min_course_price_for_currency(self.course_key, 'cny')) @@ -141,7 +144,7 @@ def test_modes_for_course_expired(self): modes = CourseMode.modes_for_course(self.course_key) self.assertEqual([CourseMode.DEFAULT_MODE], modes) - mode1 = Mode(u'honor', u'Honor Code Certificate', 0, '', 'usd', None, None, None, None) + mode1 = Mode(u'honor', u'Honor Code Certificate', 0, 0.00, '', 'usd', None, None, None, None) self.create_mode(mode1.slug, mode1.name, mode1.min_price, mode1.suggested_prices) modes = CourseMode.modes_for_course(self.course_key) self.assertEqual([mode1], modes) @@ -153,6 +156,7 @@ def test_modes_for_course_expired(self): u'verified', u'Verified Certificate', 10, + 10.00, '', 'usd', expiration_datetime, diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index fc2637cd8b52..4137456a2256 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -3,9 +3,9 @@ """ -import decimal import unittest from datetime import datetime, timedelta +from decimal import Decimal import ddt import freezegun @@ -366,7 +366,7 @@ def test_remember_donation_for_course(self): self.assertIn(six.text_type(self.course.id), self.client.session['donation_for_course']) actual_amount = self.client.session['donation_for_course'][six.text_type(self.course.id)] - expected_amount = decimal.Decimal(self.POST_PARAMS_FOR_COURSE_MODE['verified']['contribution']) + expected_amount = Decimal(self.POST_PARAMS_FOR_COURSE_MODE['verified']['contribution']) self.assertEqual(actual_amount, expected_amount) def test_successful_default_enrollment(self): @@ -410,22 +410,23 @@ def test_default_mode_creation(self): self.assertEqual(response.status_code, 200) - expected_mode = [Mode(u'honor', u'Honor Code Certificate', 0, '', 'usd', None, None, None, None)] + expected_mode = [Mode(u'honor', u'Honor Code Certificate', 0, Decimal('0.00'), '', 'usd', None, None, None, None)] course_mode = CourseMode.modes_for_course(self.course.id) self.assertEqual(course_mode, expected_mode) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @ddt.data( - (u'verified', u'Verified Certificate', 10, '10,20,30', 'usd'), - (u'professional', u'Professional Education', 100, '100,200', 'usd'), + (u'verified', u'Verified Certificate', 10, Decimal('10.00'), '10,20,30', 'usd'), + (u'professional', u'Professional Education', 100, Decimal('100.00'), '100,200', 'usd'), ) @ddt.unpack - def test_verified_mode_creation(self, mode_slug, mode_display_name, min_price, suggested_prices, currency): + def test_verified_mode_creation(self, mode_slug, mode_display_name, min_price, price, suggested_prices, currency): parameters = {} parameters['mode_slug'] = mode_slug parameters['mode_display_name'] = mode_display_name parameters['min_price'] = min_price + parameters['price'] = price parameters['suggested_prices'] = suggested_prices parameters['currency'] = currency @@ -439,6 +440,7 @@ def test_verified_mode_creation(self, mode_slug, mode_display_name, min_price, s mode_slug, mode_display_name, min_price, + price, suggested_prices, currency, None, @@ -463,14 +465,15 @@ def test_multiple_mode_creation(self): parameters['mode_slug'] = u'verified' parameters['mode_display_name'] = u'Verified Certificate' parameters['min_price'] = 10 + parameters['price'] = Decimal('10.00') parameters['suggested_prices'] = '10,20' # Create a verified mode url = reverse('create_mode', args=[six.text_type(self.course.id)]) self.client.get(url, parameters) - honor_mode = Mode(u'honor', u'Honor Code Certificate', 0, '', 'usd', None, None, None, None) - verified_mode = Mode(u'verified', u'Verified Certificate', 10, '10,20', 'usd', None, None, None, None) + honor_mode = Mode(u'honor', u'Honor Code Certificate', 0, Decimal('0.00'), '', 'usd', None, None, None, None) + verified_mode = Mode(u'verified', u'Verified Certificate', 10, Decimal('10.00'), '10,20', 'usd', None, None, None, None) expected_modes = [honor_mode, verified_mode] course_modes = CourseMode.modes_for_course(self.course.id) diff --git a/lms/djangoapps/commerce/api/v1/serializers.py b/lms/djangoapps/commerce/api/v1/serializers.py index 5c2d16f003d2..78505dbb2840 100644 --- a/lms/djangoapps/commerce/api/v1/serializers.py +++ b/lms/djangoapps/commerce/api/v1/serializers.py @@ -20,6 +20,7 @@ class CourseModeSerializer(serializers.ModelSerializer): """ CourseMode serializer. """ name = serializers.CharField(source='mode_slug') price = serializers.IntegerField(source='min_price') + price_decimal = serializers.DecimalField(source='min_price', max_digits=30, decimal_places=2) expires = serializers.DateTimeField( source='expiration_datetime', required=False, diff --git a/lms/djangoapps/commerce/api/v1/tests/test_views.py b/lms/djangoapps/commerce/api/v1/tests/test_views.py index b45ca537cd6b..3fcfd038e892 100644 --- a/lms/djangoapps/commerce/api/v1/tests/test_views.py +++ b/lms/djangoapps/commerce/api/v1/tests/test_views.py @@ -4,6 +4,7 @@ import itertools import json from datetime import datetime, timedelta +from decimal import Decimal import ddt import pytz @@ -63,6 +64,7 @@ def _serialize_course_mode(cls, course_mode): u'name': course_mode.mode_slug, u'currency': course_mode.currency.lower(), u'price': course_mode.min_price, + u'price_decimal': course_mode.min_price, u'sku': course_mode.sku, u'bulk_sku': course_mode.bulk_sku, u'expires': cls._serialize_datetime(course_mode.expiration_datetime), @@ -157,6 +159,7 @@ def _get_update_response_and_expected_data(self, mode_expiration, verification_d expected_course_mode = CourseMode( mode_slug=u'verified', min_price=200, + price=Decimal('200.00'), currency=u'USD', sku=u'ABC123', bulk_sku=u'BULK-ABC123', @@ -228,6 +231,7 @@ def test_update_remove_verification_deadline(self): verified_mode = CourseMode( mode_slug=u'verified', min_price=200, + price=Decimal('200.00'), currency=u'USD', sku=u'ABC123', bulk_sku=u'BULK-ABC123', @@ -253,6 +257,7 @@ def test_update_verification_deadline_left_alone(self): verified_mode = CourseMode( mode_slug=u'verified', min_price=200, + price=Decimal('200.00'), currency=u'USD', sku=u'ABC123', bulk_sku=u'BULK-ABC123', @@ -298,6 +303,7 @@ def test_update_overwrite(self): course_id=self.course.id, mode_slug=u'masters', min_price=10000, + price=Decimal('10000.00'), currency=u'USD', sku=u'DEF456', bulk_sku=u'BULK-DEF456' @@ -306,6 +312,7 @@ def test_update_overwrite(self): course_id=self.course.id, mode_slug=u'credit', min_price=500, + price=Decimal('500.00'), currency=u'USD', sku=u'ABC123', bulk_sku=u'BULK-ABC123' @@ -341,6 +348,7 @@ def test_update_professional_expiration(self, mode_slug, expiration_datetime_nam CourseMode( mode_slug=mode_slug, min_price=500, + price=Decimal('500.00'), currency=u'USD', sku=u'ABC123', bulk_sku=u'BULK-ABC123', diff --git a/lms/envs/common.py b/lms/envs/common.py index 8183a05c1032..da0233c97365 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1177,6 +1177,7 @@ def _make_mako_template_dirs(settings): 'description': None, 'expiration_datetime': None, 'min_price': 0, + 'price': 0.00, 'name': _(u'Audit'), 'sku': None, 'slug': u'audit', diff --git a/openedx/tests/settings.py b/openedx/tests/settings.py index fd4f3469cc5f..abe9e9490548 100644 --- a/openedx/tests/settings.py +++ b/openedx/tests/settings.py @@ -29,6 +29,7 @@ 'description': None, 'expiration_datetime': None, 'min_price': 0, + 'price': 0.00, 'name': 'Audit', 'sku': None, 'slug': 'audit',