Skip to content

Commit 8ce6ae4

Browse files
committed
Merge branch 'master' into PROD-4431/support_credit_seat_metadata
2 parents bf36fe8 + fa03d89 commit 8ce6ae4

8 files changed

Lines changed: 132 additions & 34 deletions

File tree

course_discovery/apps/api/serializers.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -967,8 +967,6 @@ def prefetch_queryset(cls, queryset=None):
967967
'_official_version',
968968
'course__partner',
969969
'restricted_run',
970-
Prefetch('seats', queryset=SeatSerializer.prefetch_queryset()),
971-
'seats'
972970
)
973971

974972
class Meta:
@@ -1184,23 +1182,32 @@ def update(self, instance, validated_data):
11841182
instance = super().update(instance, validated_data)
11851183
if seats_data is not None:
11861184
for seat in seats_data:
1187-
seat_type = getattr(seat.get('type'), 'slug', seat.get('type'))
1188-
credit_provider = seat.get('credit_provider')
1189-
credit_hours = seat.get('credit_hours')
1190-
upgrade_deadline_override = seat.get('upgrade_deadline_override')
1191-
price = seat.get('price', 0)
1192-
obj_seat, created = instance.seats.get_or_create(type=seat_type, defaults={
1193-
'price': price,
1194-
'credit_provider': credit_provider,
1195-
'credit_hours': credit_hours,
1196-
'upgrade_deadline_override': upgrade_deadline_override,
1197-
})
1185+
seat_type_value = seat.get('type')
1186+
if isinstance(seat_type_value, str):
1187+
seat_type = SeatType.objects.filter(slug=seat_type_value).first()
1188+
elif hasattr(seat_type_value, 'slug'):
1189+
seat_type = seat_type_value
1190+
else:
1191+
seat_type = None
1192+
1193+
if not seat_type:
1194+
continue # skip invalid seat data safely
1195+
1196+
defaults = {
1197+
'price': seat.get('price', 0),
1198+
'upgrade_deadline_override': seat.get('upgrade_deadline_override'),
1199+
'credit_provider': seat.get('credit_provider'),
1200+
'credit_hours': seat.get('credit_hours'),
1201+
}
1202+
1203+
obj_seat, created = instance.seats.get_or_create(type=seat_type, defaults=defaults)
11981204

11991205
if not created:
1200-
obj_seat.price = price
1201-
obj_seat.credit_provider = credit_provider
1202-
obj_seat.credit_hours = credit_hours
1203-
obj_seat.upgrade_deadline_override = upgrade_deadline_override
1206+
for field, value in defaults.items():
1207+
# Only overwrite if explicitly passed in seat data
1208+
if field in seat:
1209+
setattr(obj_seat, field, value)
1210+
obj_seat.full_clean() # ensure model validation runs
12041211
obj_seat.save()
12051212
return instance
12061213

course_discovery/apps/api/tests/test_serializers.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import ddt
88
import pytest
99
import responses
10-
from django.core.exceptions import ValidationError
1110
from django.test import TestCase
1211
from django.utils.text import slugify
1312
from django.utils.timezone import now
@@ -46,9 +45,7 @@
4645
from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin, LMSAPIClientMixin
4746
from course_discovery.apps.core.utils import serialize_datetime
4847
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
49-
from course_discovery.apps.course_metadata.models import (
50-
AbstractLocationRestrictionModel, CourseReview, CourseType, Seat
51-
)
48+
from course_discovery.apps.course_metadata.models import AbstractLocationRestrictionModel, CourseReview, CourseType
5249
from course_discovery.apps.course_metadata.search_indexes.documents import (
5350
CourseDocument, CourseRunDocument, LearnerPathwayDocument, PersonDocument, ProgramDocument
5451
)

course_discovery/apps/api/v1/views/courses.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@
3434
Collaborator, Course, CourseEditor, CourseEntitlement, CourseRun, CourseType, CourseUrlSlug, Organization, Program,
3535
Seat, Source, Video
3636
)
37+
from course_discovery.apps.course_metadata.toggles import IS_COURSE_RUN_FOR_DUMMY_SKU_GENERATION
3738
from course_discovery.apps.course_metadata.utils import (
38-
create_missing_entitlement, ensure_draft_world, validate_course_number, validate_slug_format
39+
create_missing_entitlement, ensure_draft_world, generate_sku, validate_course_number, validate_slug_format
3940
)
4041
from course_discovery.apps.publisher.utils import is_publisher_user
4142

@@ -213,7 +214,6 @@ def create(self, request, *args, **kwargs):
213214

214215
if error_message:
215216
return Response((_('Incorrect data sent. ') + error_message).strip(), status=status.HTTP_400_BAD_REQUEST)
216-
217217
partner = request.site.partner
218218
course_creation_fields['partner'] = partner.id
219219
course_creation_fields['key'] = self.get_course_key(course_creation_fields)
@@ -240,7 +240,6 @@ def create(self, request, *args, **kwargs):
240240

241241
course = serializer.save(draft=True)
242242
course.set_active_url_slug(url_slug)
243-
244243
organization = Organization.objects.get(key=course_creation_fields['org'])
245244
course.authoring_organizations.add(organization)
246245

@@ -250,6 +249,9 @@ def create(self, request, *args, **kwargs):
250249
course.collaborators.add(*collaborators)
251250

252251
entitlement_types = course.type.entitlement_types.all()
252+
# Waffle switch to control dummy SKU generation logic for 2U purpose.
253+
if IS_COURSE_RUN_FOR_DUMMY_SKU_GENERATION.is_enabled():
254+
generate_sku(partner, course) # Generates a SKU for the provide by entitlements
253255
prices = request.data.get('prices', {})
254256
for entitlement_type in entitlement_types:
255257
CourseEntitlement.objects.create(

course_discovery/apps/course_metadata/models.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@
5757
)
5858
from course_discovery.apps.course_metadata.query import CourseQuerySet, CourseRunQuerySet, ProgramQuerySet
5959
from course_discovery.apps.course_metadata.toggles import (
60-
IS_SUBDIRECTORY_SLUG_FORMAT_ENABLED, IS_SUBDIRECTORY_SLUG_FORMAT_FOR_BOOTCAMP_ENABLED,
61-
IS_SUBDIRECTORY_SLUG_FORMAT_FOR_EXEC_ED_ENABLED
60+
IS_COURSE_RUN_FOR_DUMMY_SKU_GENERATION, IS_SUBDIRECTORY_SLUG_FORMAT_ENABLED,
61+
IS_SUBDIRECTORY_SLUG_FORMAT_FOR_BOOTCAMP_ENABLED, IS_SUBDIRECTORY_SLUG_FORMAT_FOR_EXEC_ED_ENABLED
6262
)
6363
from course_discovery.apps.course_metadata.utils import (
6464
UploadToFieldNamePath, bulk_operation_upload_to_path, clean_query, clear_slug_request_cache_for_course,
65-
custom_render_variations, get_course_run_statuses, get_slug_for_course, is_ocm_course,
65+
custom_render_variations, generate_sku, get_course_run_statuses, get_slug_for_course, is_ocm_course,
6666
push_to_ecommerce_for_course_run, push_tracks_to_lms_for_course_run, set_official_state, subtract_deadline_delta,
6767
validate_ai_languages
6868
)
@@ -2810,10 +2810,10 @@ def get_seat_default_upgrade_deadline(self, seat_type):
28102810
return None
28112811
return subtract_deadline_delta(self.end, settings.PUBLISHER_UPGRADE_DEADLINE_DAYS)
28122812

2813-
def update_or_create_seat_helper(self, seat_type, prices, upgrade_deadline_override, credit_provider=None, credit_hours=None):
2813+
def update_or_create_seat_helper(self, seat_type, prices, upgrade_deadline_override,
2814+
credit_provider=None, credit_hours=None):
28142815
default_deadline = self.get_seat_default_upgrade_deadline(seat_type)
28152816
defaults = {'upgrade_deadline': default_deadline}
2816-
28172817
if seat_type.slug in prices:
28182818
defaults['price'] = prices[seat_type.slug]
28192819
if seat_type.slug == Seat.VERIFIED:
@@ -2822,6 +2822,9 @@ def update_or_create_seat_helper(self, seat_type, prices, upgrade_deadline_overr
28222822
defaults['credit_provider'] = credit_provider
28232823
defaults['credit_hours'] = credit_hours
28242824

2825+
# Waffle switch to control dummy SKU generation logic for 2U purpose.
2826+
if IS_COURSE_RUN_FOR_DUMMY_SKU_GENERATION.is_enabled():
2827+
generate_sku(None, self) # Generates a SKU for the provide by Seat
28252828
seat, __ = Seat.everything.update_or_create(
28262829
course_run=self,
28272830
type=seat_type,
@@ -2831,7 +2834,8 @@ def update_or_create_seat_helper(self, seat_type, prices, upgrade_deadline_overr
28312834
)
28322835
return seat
28332836

2834-
def update_or_create_seats(self, run_type=None, prices=None, upgrade_deadline_override=None, credit_provider=None, credit_hours=None):
2837+
def update_or_create_seats(self, run_type=None, prices=None, upgrade_deadline_override=None,
2838+
credit_provider=None, credit_hours=None):
28352839
"""
28362840
Updates or creates draft seats for a course run.
28372841
@@ -2846,7 +2850,9 @@ def update_or_create_seats(self, run_type=None, prices=None, upgrade_deadline_ov
28462850

28472851
seats = []
28482852
for seat_type in seat_types:
2849-
seats.append(self.update_or_create_seat_helper(seat_type, prices, upgrade_deadline_override, credit_provider=credit_provider, credit_hours=credit_hours))
2853+
seats.append(self.update_or_create_seat_helper(
2854+
seat_type, prices, upgrade_deadline_override,
2855+
credit_provider=credit_provider, credit_hours=credit_hours))
28502856

28512857
# Deleting seats here since they would be orphaned otherwise.
28522858
# One example of how this situation can happen is if a course team is switching between

course_discovery/apps/course_metadata/tests/test_models.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,11 +1790,33 @@ def test_upgrade_deadline_property_prefers_override(self):
17901790
"""upgrade_deadline should return override if present, else fallback to _upgrade_deadline."""
17911791
seat = factories.SeatFactory(upgrade_deadline_override=None)
17921792
# Initially falls back to _upgrade_deadline
1793-
assert seat.upgrade_deadline == seat._upgrade_deadline
1793+
assert seat.upgrade_deadline == seat._upgrade_deadline # pylint: disable=protected-access
17941794
# Override takes precedence
1795-
seat.upgrade_deadline_override = seat._upgrade_deadline.replace(year=seat._upgrade_deadline.year + 1)
1795+
seat.upgrade_deadline_override = seat._upgrade_deadline.replace(year=seat._upgrade_deadline.year + 1) # pylint: disable=protected-access
17961796
assert seat.upgrade_deadline == seat.upgrade_deadline_override
17971797

1798+
@patch('course_discovery.apps.course_metadata.models.IS_COURSE_RUN_FOR_DUMMY_SKU_GENERATION')
1799+
@patch('course_discovery.apps.course_metadata.models.generate_sku')
1800+
def test_generate_sku_called_when_waffle_enabled(self, mock_generate_sku, mock_switch):
1801+
mock_switch.is_enabled.return_value = True # Force switch to be active
1802+
course_run = factories.CourseRunFactory.create(key='course-v1:org1+12+2T2025b')
1803+
factories.SeatFactory.create(course_run=course_run)
1804+
seat_type = SeatType.objects.create(slug='verified')
1805+
prices = {'verified': 500}
1806+
course_run.update_or_create_seat_helper(seat_type, prices, upgrade_deadline_override=None)
1807+
mock_generate_sku.assert_called_once_with(None, course_run)
1808+
1809+
@patch('course_discovery.apps.course_metadata.models.IS_COURSE_RUN_FOR_DUMMY_SKU_GENERATION')
1810+
@patch('course_discovery.apps.course_metadata.models.generate_sku')
1811+
def test_generate_sku_called_when_waffle_disable(self, mock_generate_sku, mock_switch):
1812+
mock_switch.is_enabled.return_value = False # Force switch to be active
1813+
course_run = factories.CourseRunFactory.create(key='course-v1:org1+12+2T2025b')
1814+
factories.SeatFactory.create(course_run=course_run)
1815+
seat_type = SeatType.objects.create(slug='verified')
1816+
prices = {'verified': 500}
1817+
course_run.update_or_create_seat_helper(seat_type, prices, upgrade_deadline_override=None)
1818+
mock_generate_sku.assert_not_called()
1819+
17981820

17991821
class CourseRunTestsThatNeedSetUp(OAuth2Mixin, TestCase):
18001822
"""

course_discovery/apps/course_metadata/tests/test_utils.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from course_discovery.apps.course_metadata.utils import (
4444
calculated_seat_upgrade_deadline, clean_html, convert_svg_to_png_from_url, create_missing_entitlement,
4545
download_and_save_course_image, download_and_save_program_image, ensure_draft_world, fetch_getsmarter_products,
46-
is_google_drive_url, serialize_entitlement_for_ecommerce_api, serialize_seat_for_ecommerce_api,
46+
generate_sku, is_google_drive_url, serialize_entitlement_for_ecommerce_api, serialize_seat_for_ecommerce_api,
4747
transform_skills_data, validate_slug_format
4848
)
4949

@@ -1676,3 +1676,34 @@ def test_validate_slug_format__raise_exception_for_bootcamp_course(self, slug, i
16761676
expected_error_message = expected_error_message.format(url_slug=slug)
16771677
actual_error_message = str(context.exception)
16781678
self.assertIn(expected_error_message, actual_error_message)
1679+
1680+
1681+
@ddt.ddt
1682+
class ValidateDummySKU(TestCase):
1683+
"""
1684+
Test suite for validate generated Dummy SKU by generate_sku method
1685+
"""
1686+
def test_generate_sku_with_partner(self):
1687+
partner = mock.Mock(id=101)
1688+
course = mock.Mock(uuid='abc-uuid')
1689+
sku = generate_sku(partner=partner, course=course)
1690+
self.assertIsInstance(sku, str)
1691+
self.assertEqual(len(sku), 7)
1692+
1693+
def test_generate_sku_without_partner(self):
1694+
course = mock.Mock(uuid='abc-uuid', key='course-key')
1695+
sku = generate_sku(partner=None, course=course)
1696+
self.assertIsInstance(sku, str)
1697+
self.assertEqual(len(sku), 7)
1698+
1699+
def test_generate_sku_invalid_combination(self):
1700+
partner = mock.Mock(id=None)
1701+
course = mock.Mock(uuid='abc-uuid')
1702+
with self.assertRaises(ValidationError) as context:
1703+
generate_sku(partner=partner, course=course)
1704+
self.assertIn("Unexpected combition SKU", str(context.exception))
1705+
1706+
def test_generate_sku_missing_course(self):
1707+
partner = mock.Mock(id=101)
1708+
with self.assertRaises(ValidationError):
1709+
generate_sku(partner=partner, course=None)

course_discovery/apps/course_metadata/toggles.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,14 @@
7878
IS_COURSE_RUN_VARIANT_ID_ECOMMERCE_CONSUMABLE = WaffleSwitch(
7979
'course_metadata.is_course_run_variant_id_ecommerce_consumable', __name__
8080
)
81+
# .. toggle_name: course_metadata.is_dummy_sku_generation
82+
# .. toggle_implementation: WaffleSwitch
83+
# .. toggle_default: False
84+
# .. toggle_description: Enable dummy SKU generation for 2U purposes,
85+
# .. toggle_use_cases: open_edx
86+
# .. toggle_creation_date: 2025-09-16
87+
# .. toggle_target_removal_date: None
88+
# .. toggle_tickets: PROD-4430
89+
IS_COURSE_RUN_FOR_DUMMY_SKU_GENERATION = WaffleSwitch(
90+
'course_metadata.is_dummy_sku_generation', __name__
91+
)

course_discovery/apps/course_metadata/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55
import string
66
import uuid
7+
from hashlib import md5
78
from tempfile import NamedTemporaryFile
89
from urllib.parse import urljoin, urlparse
910

@@ -1291,3 +1292,24 @@ def bulk_operation_upload_to_path(instance, filename): # pylint: disable=unused
12911292
Utility method used on BulkOperationTask csv_file field to generate unique file names.
12921293
"""
12931294
return f"bulk_operations/uploads/{str(uuid.uuid4())}/{filename}"
1295+
1296+
1297+
def generate_sku(partner=None, course=None):
1298+
"""
1299+
Generates a SKU for the provide by entitlements and seats combination.
1300+
Example: 76E4E71
1301+
"""
1302+
try:
1303+
if partner and getattr(partner, 'id', None) and course is not None:
1304+
_hash = ' '.join((str(course.uuid), str(partner.id))).encode('utf-8')
1305+
logger.info('Initiating SKU generation for the course entitlements.')
1306+
elif partner is None:
1307+
_hash = ' '.join((str(course.uuid), str(course.key))).encode('utf-8')
1308+
logger.info('Initiating SKU generation for the seats.')
1309+
else:
1310+
raise Exception('Unexpected entitlements and seats')
1311+
md5_hash = md5(_hash.lower())
1312+
digest = md5_hash.hexdigest()[-7:]
1313+
return digest.upper()
1314+
except Exception as exc:
1315+
raise ValidationError("Unexpected combition SKU") from exc

0 commit comments

Comments
 (0)