diff --git a/common/djangoapps/course_modes/admin.py b/common/djangoapps/course_modes/admin.py index 1b3ad262e4..4563234566 100644 --- a/common/djangoapps/course_modes/admin.py +++ b/common/djangoapps/course_modes/admin.py @@ -191,6 +191,8 @@ class CourseModeAdmin(admin.ModelAdmin): '_expiration_datetime', 'verification_deadline', 'sku', + 'android_sku', + 'ios_sku', 'bulk_sku' ) @@ -203,6 +205,8 @@ class CourseModeAdmin(admin.ModelAdmin): 'min_price', 'expiration_datetime_custom', 'sku', + 'android_sku', + 'ios_sku', 'bulk_sku' ) diff --git a/common/djangoapps/course_modes/migrations/0014_auto_20230207_1212.py b/common/djangoapps/course_modes/migrations/0014_auto_20230207_1212.py new file mode 100644 index 0000000000..40bf433047 --- /dev/null +++ b/common/djangoapps/course_modes/migrations/0014_auto_20230207_1212.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.16 on 2023-02-07 12:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_modes', '0013_auto_20200115_2022'), + ] + + operations = [ + migrations.AddField( + model_name='coursemode', + name='android_sku', + field=models.CharField(blank=True, help_text='OPTIONAL: This is the Android SKU registered on play store for this mode of the course. Leave this blank if the course has not yet been migrated to the ecommerce service.', max_length=255, null=True, verbose_name='Android SKU'), + ), + migrations.AddField( + model_name='coursemode', + name='ios_sku', + field=models.CharField(blank=True, help_text='OPTIONAL: This is the iOS SKU registered on app store for this mode of the course. Leave this blank if the course has not yet been migrated to the ecommerce service.', max_length=255, null=True, verbose_name='iOS SKU'), + ), + migrations.AddField( + model_name='historicalcoursemode', + name='android_sku', + field=models.CharField(blank=True, help_text='OPTIONAL: This is the Android SKU registered on play store for this mode of the course. Leave this blank if the course has not yet been migrated to the ecommerce service.', max_length=255, null=True, verbose_name='Android SKU'), + ), + migrations.AddField( + model_name='historicalcoursemode', + name='ios_sku', + field=models.CharField(blank=True, help_text='OPTIONAL: This is the iOS SKU registered on app store for this mode of the course. Leave this blank if the course has not yet been migrated to the ecommerce service.', max_length=255, null=True, verbose_name='iOS SKU'), + ), + ] diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 1cb7161fe2..2d92a99d2c 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -35,6 +35,8 @@ Mode = namedtuple('Mode', 'expiration_datetime', 'description', 'sku', + 'android_sku', + 'ios_sku', 'bulk_sku', ]) @@ -114,6 +116,30 @@ class CourseMode(models.Model): ) ) + # Optional Android SKU for integration with mobile and the ecommerce service + android_sku = models.CharField( + max_length=255, + null=True, + blank=True, + verbose_name="Android SKU", + help_text=_( + "OPTIONAL: This is the Android SKU registered on play store for this mode of the course. " + "Leave this blank if the course has not yet been migrated to the ecommerce service." + ) + ) + + # Optional iOS SKU for integration with mobile and the ecommerce service + ios_sku = models.CharField( + max_length=255, + null=True, + blank=True, + verbose_name="iOS SKU", + help_text=_( + "OPTIONAL: This is the iOS SKU registered on app store for this mode of the course. " + "Leave this blank if the course has not yet been migrated to the ecommerce service." + ) + ) + # Optional bulk order SKU for integration with the ecommerce service bulk_sku = models.CharField( max_length=255, @@ -150,6 +176,8 @@ class CourseMode(models.Model): settings.COURSE_MODE_DEFAULTS['expiration_datetime'], settings.COURSE_MODE_DEFAULTS['description'], settings.COURSE_MODE_DEFAULTS['sku'], + settings.COURSE_MODE_DEFAULTS['android_sku'], + settings.COURSE_MODE_DEFAULTS['ios_sku'], settings.COURSE_MODE_DEFAULTS['bulk_sku'], ) DEFAULT_MODE_SLUG = settings.COURSE_MODE_DEFAULTS['slug'] @@ -817,6 +845,8 @@ class CourseMode(models.Model): self.expiration_datetime, self.description, self.sku, + self.android_sku, + self.ios_sku, self.bulk_sku ) diff --git a/common/djangoapps/course_modes/tests/test_models.py b/common/djangoapps/course_modes/tests/test_models.py index 9ce9e95c8e..35ea05f692 100644 --- a/common/djangoapps/course_modes/tests/test_models.py +++ b/common/djangoapps/course_modes/tests/test_models.py @@ -95,7 +95,7 @@ class CourseModeModelTest(TestCase): self.create_mode('verified', 'Verified Certificate', 10) modes = CourseMode.modes_for_course(self.course_key) - mode = Mode('verified', 'Verified Certificate', 10, '', 'usd', None, None, None, None) + mode = Mode('verified', 'Verified Certificate', 10, '', 'usd', None, None, None, None, None, None) assert [mode] == modes modes_dict = CourseMode.modes_for_course_dict(self.course_key) @@ -106,8 +106,8 @@ class CourseModeModelTest(TestCase): """ Finding the modes when there's multiple modes """ - mode1 = Mode('honor', 'Honor Code Certificate', 0, '', 'usd', None, None, None, None) - mode2 = Mode('verified', 'Verified Certificate', 10, '', 'usd', None, None, None, None) + mode1 = Mode('honor', 'Honor Code Certificate', 0, '', 'usd', None, None, None, None, None, None) + mode2 = Mode('verified', 'Verified Certificate', 10, '', 'usd', None, None, 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) @@ -126,14 +126,14 @@ class CourseModeModelTest(TestCase): assert 0 == CourseMode.min_course_price_for_currency(self.course_key, 'usd') # with mode with other currency, should get 0 - mode = Mode('audit', 'Audit', 30, '', 'eur', None, None, None, None) + mode = Mode('audit', 'Audit', 30, '', 'eur', None, None, None, None, None, None) self.create_mode(mode.slug, mode.name, mode.min_price, mode.suggested_prices, mode.currency) assert 0 == CourseMode.min_course_price_for_currency(self.course_key, 'usd') # create some modes - mode1 = Mode('honor', 'Honor Code Certificate', 10, '', 'usd', None, None, None, None) - mode2 = Mode('verified', 'Verified Certificate', 20, '', 'usd', None, None, None, None) - mode3 = Mode('honor', 'Honor Code Certificate', 80, '', 'cny', None, None, None, None) + mode1 = Mode('honor', 'Honor Code Certificate', 10, '', 'usd', None, None, None, None, None, None) + mode2 = Mode('verified', 'Verified Certificate', 20, '', 'usd', None, None, None, None, None, None) + mode3 = Mode('honor', 'Honor Code Certificate', 80, '', 'cny', None, None, 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) @@ -148,7 +148,7 @@ class CourseModeModelTest(TestCase): modes = CourseMode.modes_for_course(self.course_key) assert [CourseMode.DEFAULT_MODE] == modes - mode1 = Mode('honor', 'Honor Code Certificate', 0, '', 'usd', None, None, None, None) + mode1 = Mode('honor', 'Honor Code Certificate', 0, '', 'usd', None, None, 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) assert [mode1] == modes @@ -165,6 +165,8 @@ class CourseModeModelTest(TestCase): expiration_datetime, None, None, + None, + None, None ) modes = CourseMode.modes_for_course(self.course_key) diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index afbd206927..5225409447 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -407,7 +407,7 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest assert response.status_code == 200 - expected_mode = [Mode('honor', 'Honor Code Certificate', 0, '', 'usd', None, None, None, None)] + expected_mode = [Mode('honor', 'Honor Code Certificate', 0, '', 'usd', None, None, None, None, None, None)] course_mode = CourseMode.modes_for_course(self.course.id) assert course_mode == expected_mode @@ -441,6 +441,8 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest None, None, None, + None, + None, None ) ] @@ -466,8 +468,8 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest url = reverse('create_mode', args=[str(self.course.id)]) self.client.get(url, parameters) - honor_mode = Mode('honor', 'Honor Code Certificate', 0, '', 'usd', None, None, None, None) - verified_mode = Mode('verified', 'Verified Certificate', 10, '10,20', 'usd', None, None, None, None) + honor_mode = Mode('honor', 'Honor Code Certificate', 0, '', 'usd', None, None, None, None, None, None) + verified_mode = Mode('verified', 'Verified Certificate', 10, '10,20', 'usd', None, None, None, None, None, None) expected_modes = [honor_mode, verified_mode] course_modes = CourseMode.modes_for_course(self.course.id) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index a4418696fd..446bfc3dc2 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -6,6 +6,7 @@ Serializer for user API from rest_framework import serializers from rest_framework.reverse import reverse +from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment, User from common.djangoapps.util.course import get_encoded_course_sharing_utm_params, get_link_for_about_page from lms.djangoapps.certificates.api import certificate_downloadable_status @@ -91,6 +92,7 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): course = CourseOverviewField(source="course_overview", read_only=True) certificate = serializers.SerializerMethodField() audit_access_expires = serializers.SerializerMethodField() + course_modes = serializers.SerializerMethodField() def get_audit_access_expires(self, model): """ @@ -110,9 +112,22 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): else: return {} + def get_course_modes(self, obj): + """ + Retrieve course modes associated with the course. + """ + course_modes = CourseMode.modes_for_course( + obj.course.id, + only_selectable=False + ) + return [ + ModeSerializer(mode).data + for mode in course_modes + ] + class Meta: model = CourseEnrollment - fields = ('audit_access_expires', 'created', 'mode', 'is_active', 'course', 'certificate') + fields = ('audit_access_expires', 'created', 'mode', 'is_active', 'course', 'certificate', 'course_modes') lookup_field = 'username' @@ -150,3 +165,18 @@ class UserSerializer(serializers.ModelSerializer): lookup_field = 'username' # For disambiguating within the drf-yasg swagger schema ref_name = 'mobile_api.User' + + +class ModeSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializes a course's 'Mode' tuples + + Returns a serialized representation of the modes available for course enrollment. The course + modes models are designed to return a tuple instead of the model object itself. This serializer + handles the given tuple. + + """ + slug = serializers.CharField(max_length=100) + sku = serializers.CharField() + android_sku = serializers.CharField() + ios_sku = serializers.CharField() diff --git a/lms/envs/common.py b/lms/envs/common.py index 9704bc8193..c4fc16b59b 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1409,10 +1409,12 @@ WIKI_ENABLED = True ### COURSE_MODE_DEFAULTS = { + 'android_sku': None, 'bulk_sku': None, 'currency': 'usd', 'description': None, 'expiration_datetime': None, + 'ios_sku': None, 'min_price': 0, 'name': _('Audit'), 'sku': None,