Unverified Commit b49939fb by McKenzie Welter Committed by GitHub

Merge pull request #16619 from edx/WIP-lms-bundling-button

Program Details Page one-click purchase button with entitlements
parents c25e4ba0 f72cf800
......@@ -136,10 +136,10 @@ class CourseMode(models.Model):
HONOR = 'honor'
PROFESSIONAL = 'professional'
VERIFIED = "verified"
AUDIT = "audit"
NO_ID_PROFESSIONAL_MODE = "no-id-professional"
CREDIT_MODE = "credit"
VERIFIED = 'verified'
AUDIT = 'audit'
NO_ID_PROFESSIONAL_MODE = 'no-id-professional'
CREDIT_MODE = 'credit'
DEFAULT_MODE = Mode(
settings.COURSE_MODE_DEFAULTS['slug'],
......
......@@ -24,3 +24,10 @@ class CourseEntitlement(TimeStampedModel):
help_text='The current Course enrollment for this entitlement. If NULL the Learner has not enrolled.'
)
order_number = models.CharField(max_length=128, null=True)
@property
def expired_at_datetime(self):
"""
Getter to be used instead of expired_at because of the conditional check and update
"""
return self.expired_at
......@@ -8,6 +8,7 @@ from faker import Faker
fake = Faker()
VERIFIED_MODE = 'verified'
def generate_instances(factory_class, count=3):
......@@ -103,10 +104,18 @@ class SeatFactory(DictFactoryBase):
currency = 'USD'
price = factory.Faker('random_int')
sku = factory.LazyFunction(generate_seat_sku)
type = 'verified'
type = VERIFIED_MODE
upgrade_deadline = factory.LazyFunction(generate_zulu_datetime)
class EntitlementFactory(DictFactoryBase):
currency = 'USD'
price = factory.Faker('random_int')
sku = factory.LazyFunction(generate_seat_sku)
mode = VERIFIED_MODE
expires = None
class CourseRunFactory(DictFactoryBase):
eligible_for_financial_aid = True
end = factory.LazyFunction(generate_zulu_datetime)
......@@ -121,7 +130,7 @@ class CourseRunFactory(DictFactoryBase):
start = factory.LazyFunction(generate_zulu_datetime)
status = 'published'
title = factory.Faker('catch_phrase')
type = 'verified'
type = VERIFIED_MODE
uuid = factory.Faker('uuid4')
content_language = 'en'
max_effort = 4
......@@ -130,6 +139,7 @@ class CourseRunFactory(DictFactoryBase):
class CourseFactory(DictFactoryBase):
course_runs = factory.LazyFunction(partial(generate_instances, CourseRunFactory))
entitlements = factory.LazyFunction(partial(generate_instances, EntitlementFactory))
image = ImageFactory()
key = factory.LazyFunction(generate_course_key)
owners = factory.LazyFunction(partial(generate_instances, OrganizationFactory, count=1))
......
......@@ -16,6 +16,7 @@ from nose.plugins.attrib import attr
from pytz import utc
from course_modes.models import CourseMode
from entitlements.tests.factories import CourseEntitlementFactory
from lms.djangoapps.certificates.api import MODES
from lms.djangoapps.commerce.tests.test_utils import update_commerce_config
from lms.djangoapps.commerce.utils import EcommerceService
......@@ -23,6 +24,7 @@ from lms.djangoapps.grades.tests.utils import mock_passing_grade
from openedx.core.djangoapps.catalog.tests.factories import (
CourseFactory,
CourseRunFactory,
EntitlementFactory,
ProgramFactory,
SeatFactory,
generate_course_run_key
......@@ -63,7 +65,7 @@ class TestProgramProgressMeter(TestCase):
def _create_enrollments(self, *course_run_ids):
"""Variadic helper used to create course run enrollments."""
for course_run_id in course_run_ids:
CourseEnrollmentFactory(user=self.user, course_id=course_run_id, mode='verified')
CourseEnrollmentFactory(user=self.user, course_id=course_run_id, mode=CourseMode.VERIFIED)
def _assert_progress(self, meter, *progresses):
"""Variadic helper used to verify progress calculations."""
......@@ -225,22 +227,22 @@ class TestProgramProgressMeter(TestCase):
course_run_key = generate_course_run_key()
now = datetime.datetime.now(utc)
upgrade_deadline = None if not offset else str(now + datetime.timedelta(days=offset))
required_seat = SeatFactory(type='verified', upgrade_deadline=upgrade_deadline)
enrolled_seat = SeatFactory(type='audit')
required_seat = SeatFactory(type=CourseMode.VERIFIED, upgrade_deadline=upgrade_deadline)
enrolled_seat = SeatFactory(type=CourseMode.AUDIT)
seats = [required_seat, enrolled_seat]
data = [
ProgramFactory(
courses=[
CourseFactory(course_runs=[
CourseRunFactory(key=course_run_key, type='verified', seats=seats),
CourseRunFactory(key=course_run_key, type=CourseMode.VERIFIED, seats=seats),
]),
]
)
]
mock_get_programs.return_value = data
CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode='audit')
CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode=CourseMode.AUDIT)
meter = ProgramProgressMeter(self.site, self.user)
......@@ -537,7 +539,9 @@ class TestProgramProgressMeter(TestCase):
Verify that the method can find course run certificates when not mocked out.
"""
mock_get_certificates_for_user.return_value = [
self._make_certificate_result(status='downloadable', type='verified', course_key='downloadable-course'),
self._make_certificate_result(
status='downloadable', type=CourseMode.VERIFIED, course_key='downloadable-course'
),
self._make_certificate_result(status='generating', type='honor', course_key='generating-course'),
self._make_certificate_result(status='unknown', course_key='unknown-course'),
]
......@@ -546,7 +550,7 @@ class TestProgramProgressMeter(TestCase):
self.assertEqual(
meter.completed_course_runs,
[
{'course_run_id': 'downloadable-course', 'type': 'verified'},
{'course_run_id': 'downloadable-course', 'type': CourseMode.VERIFIED},
{'course_run_id': 'generating-course', 'type': 'honor'},
]
)
......@@ -558,9 +562,10 @@ class TestProgramProgressMeter(TestCase):
Verify that 'no-id-professional' certificates are treated as if they were
'professional' certificates when determining program completion.
"""
# Create serialized course runs like the ones we expect to receive from
# the discovery service's API. These runs are of type 'professional'.
course_runs = CourseRunFactory.create_batch(2, type='professional')
# Create serialized course runs like the ones we expect to receive from the discovery service's API.
# These runs are of type 'professional' because there is no seat type for no-id-professional;
# it uses professional as the seat type instead.
course_runs = CourseRunFactory.create_batch(2, type=CourseMode.PROFESSIONAL)
program = ProgramFactory(courses=[CourseFactory(course_runs=course_runs)])
mock_get_programs.return_value = [program]
......@@ -571,7 +576,9 @@ class TestProgramProgressMeter(TestCase):
# Grant a 'no-id-professional' certificate for one of the course runs,
# thereby completing the program.
mock_get_certificates_for_user.return_value = [
self._make_certificate_result(status='downloadable', type='no-id-professional', course_key=course_runs[0]['key'])
self._make_certificate_result(
status='downloadable', type=CourseMode.NO_ID_PROFESSIONAL_MODE, course_key=course_runs[0]['key']
)
]
# Verify that the program is complete.
......@@ -592,7 +599,7 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = [program]
self._create_enrollments(course_run_key)
meter = ProgramProgressMeter(self.site, self.user)
mock_completed_course_runs.return_value = [{'course_run_id': course_run_key, 'type': 'verified'}]
mock_completed_course_runs.return_value = [{'course_run_id': course_run_key, 'type': CourseMode.VERIFIED}]
self.assertEqual(meter._is_course_complete(course), True)
def test_course_grade_results(self, mock_get_programs):
......@@ -628,7 +635,7 @@ class TestProgramProgressMeter(TestCase):
self.assertEqual(meter.progress(count_only=False), expected)
def _create_course(self, course_price, course_run_count=1):
def _create_course(self, course_price, course_run_count=1, make_entitlement=False):
"""
Creates the course in mongo and update it with the instructor data.
Also creates catalog course with respect to course run.
......@@ -646,8 +653,9 @@ def _create_course(self, course_price, course_run_count=1):
run = CourseRunFactory(key=unicode(course.id), seats=[SeatFactory(price=course_price)])
course_runs.append(run)
entitlements = [EntitlementFactory()] if make_entitlement else []
return CourseFactory(course_runs=course_runs)
return CourseFactory(course_runs=course_runs, entitlements=entitlements)
@ddt.ddt
......@@ -879,12 +887,12 @@ class TestProgramDataExtender(ModuleStoreTestCase):
course1 = _create_course(self, self.course_price)
course2 = _create_course(self, self.course_price)
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode='verified')
CourseEnrollmentFactory(user=self.user, course_id=course2['course_runs'][0]['key'], mode='audit')
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED)
CourseEnrollmentFactory(user=self.user, course_id=course2['course_runs'][0]['key'], mode=CourseMode.AUDIT)
program2 = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=['verified'],
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program2, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
......@@ -897,12 +905,12 @@ class TestProgramDataExtender(ModuleStoreTestCase):
"""
course1 = _create_course(self, self.course_price, course_run_count=2)
course2 = _create_course(self, self.course_price)
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode='verified')
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED)
course1['course_runs'][0]['status'] = 'unpublished'
program2 = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=['verified'],
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program2, self.user).extend()
self.assertEqual(len(data['skus']), 1)
......@@ -915,12 +923,13 @@ class TestProgramDataExtender(ModuleStoreTestCase):
This test is primarily for the case of no-id-professional enrollment modes
"""
course1 = _create_course(self, self.course_price)
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode='no-id-professional')
CourseEnrollmentFactory(
user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.NO_ID_PROFESSIONAL_MODE
)
program2 = ProgramFactory(
courses=[course1],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=['professional'], # There is no seat type for no-id-professional, it
# instead uses professional
applicable_seat_types=[CourseMode.PROFESSIONAL]
)
data = ProgramDataExtender(program2, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase'])
......@@ -938,7 +947,7 @@ class TestProgramDataExtender(ModuleStoreTestCase):
key=str(ModuleStoreCourseFactory().id),
status='published'
)
course = CourseFactory(course_runs=[course_run_1, course_run_2])
course = CourseFactory(course_runs=[course_run_1, course_run_2], entitlements=[])
program = ProgramFactory(
courses=[
CourseFactory(course_runs=[
......@@ -956,7 +965,7 @@ class TestProgramDataExtender(ModuleStoreTestCase):
])
],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=['verified']
applicable_seat_types=[CourseMode.VERIFIED]
)
data = ProgramDataExtender(program, self.user).extend()
......@@ -967,6 +976,147 @@ class TestProgramDataExtender(ModuleStoreTestCase):
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
def test_learner_eligibility_for_one_click_purchase_entitlement_products(self):
"""
Learner should be eligible for one click purchase if:
- program is eligible for one click purchase
- There are remaining unpurchased courses with entitlement products
"""
course1 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
expected_skus = set([course1['entitlements'][0]['sku'], course2['entitlements'][0]['sku']])
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(set(data['skus']), expected_skus)
def test_learner_eligibility_for_one_click_purchase_ineligible_program(self):
"""
Learner should not be eligible for one click purchase if the program is not eligible for one click purchase
"""
course1 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=False,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(data['skus'], [])
def test_learner_eligibility_for_one_click_purchase_user_entitlements(self):
"""
Learner should be eligibile for one click purchase if they hold an entitlement in one or more courses
in the program and there are remaining unpurchased courses in the program with entitlement products.
"""
course1 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
CourseEntitlementFactory(user=self.user, course_uuid=course1['uuid'], mode=CourseMode.VERIFIED)
expected_skus = set([course2['entitlements'][0]['sku']])
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(set(data['skus']), expected_skus)
def test_all_courses_owned(self):
"""
Learner should not be eligible for one click purchase if they hold entitlements in all courses in the program.
"""
course1 = _create_course(self, self.course_price, make_entitlement=True)
course2 = _create_course(self, self.course_price)
CourseEntitlementFactory(user=self.user, course_uuid=course1['uuid'], mode=CourseMode.VERIFIED)
CourseEntitlementFactory(user=self.user, course_uuid=course2['uuid'], mode=CourseMode.VERIFIED)
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(data['skus'], [])
def test_entitlement_product_wrong_mode(self):
"""
Learner should not be eligible for one click purchase if the only entitlement product
for a course in the program is not in an applicable mode, and that course has multiple course runs.
"""
course1 = _create_course(self, self.course_price)
course2 = _create_course(self, self.course_price, course_run_count=2)
course2['entitlements'].append(EntitlementFactory(mode=CourseMode.PROFESSIONAL))
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(data['skus'], [])
def test_second_entitlement_product_wrong_mode(self):
"""
Learner should be eligible for one click purchase if a course has multiple entitlement products
and at least one of them is in an applicable mode, even if one is not in an applicable mode.
"""
course1 = _create_course(self, self.course_price)
course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
# The above statement makes a verfied entitlement for the course, which is an applicable seat type
# and the statement below makes a professional entitlement for the same course, which is not applicable
course2['entitlements'].append(EntitlementFactory(mode=CourseMode.PROFESSIONAL))
expected_skus = set([course1['course_runs'][0]['seats'][0]['sku'], course2['entitlements'][0]['sku']])
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(set(data['skus']), expected_skus)
def test_entitlement_product_and_user_enrollment(self):
"""
Learner should be eligible for one click purchase if they hold an enrollment
but not an entitlement in a course for which there exists an entitlement product.
"""
course1 = _create_course(self, self.course_price, make_entitlement=True)
course2 = _create_course(self, self.course_price)
expected_skus = set([course2['course_runs'][0]['seats'][0]['sku']])
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED)
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(set(data['skus']), expected_skus)
def test_user_enrollment_with_other_course_entitlement_product(self):
"""
Learner should be eligible for one click purchase if they hold an enrollment in one course of the program
and there is an entitlement product for another course in the program.
"""
course1 = _create_course(self, self.course_price, course_run_count=2)
course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True)
CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED)
expected_skus = set([course2['entitlements'][0]['sku']])
program = ProgramFactory(
courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=[CourseMode.VERIFIED, CourseMode.PROFESSIONAL],
)
data = ProgramDataExtender(program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
self.assertEqual(set(data['skus']), expected_skus)
@skip_unless_lms
@mock.patch(UTILS_MODULE + '.get_credentials')
......@@ -1095,7 +1245,7 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
self.number_of_courses = 2
self.program = ProgramFactory(
courses=[_create_course(self, self.course_price) for __ in range(self.number_of_courses)],
applicable_seat_types=['verified']
applicable_seat_types=[CourseMode.VERIFIED]
)
def _prepare_program_for_discounted_price_calculation_endpoint(self):
......@@ -1212,8 +1362,9 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
body=json.dumps(mock_discount_data),
content_type='application/json'
)
user = AnonymousUserFactory()
data = ProgramMarketingDataExtender(self.program, AnonymousUserFactory()).extend()
data = ProgramMarketingDataExtender(self.program, user).extend()
self._update_discount_data(mock_discount_data)
self.assertEqual(
......
......@@ -460,57 +460,99 @@ class ProgramDataExtender(object):
def _attach_course_run_may_certify(self, run_mode):
run_mode['may_certify'] = self.course_overview.may_certify()
def _check_enrollment_for_user(self, course_run):
applicable_seat_types = self.data['applicable_seat_types']
def _filter_out_courses_with_entitlements(self, courses):
"""
Removes courses for which the current user already holds an applicable entitlement.
TODO:
Add a NULL value of enrollment_course_run to filter, as courses with entitlements spent on applicable
enrollments will already have been filtered out by _filter_out_courses_with_enrollments.
(enrollment_mode, active) = CourseEnrollment.enrollment_mode_for_user(
self.user,
CourseKey.from_string(course_run['key'])
Arguments:
courses (list): Containing dicts representing courses in a program
Returns:
A subset of the given list of course dicts
"""
course_uuids = set(course['uuid'] for course in courses)
# Filter the entitlements' modes with a case-insensitive match against applicable seat_types
entitlements = self.user.courseentitlement_set.filter(
mode__in=self.data['applicable_seat_types'],
course_uuid__in=course_uuids,
)
# Here we check the entitlements' expired_at_datetime property rather than filter by the expired_at attribute
# to ensure that the expiration status is as up to date as possible
entitlements = [e for e in entitlements if not e.expired_at_datetime]
courses_with_entitlements = set(unicode(entitlement.course_uuid) for entitlement in entitlements)
return [course for course in courses if course['uuid'] not in courses_with_entitlements]
is_paid_seat = False
if enrollment_mode is not None and active is not None and active is True:
# Check all the applicable seat types
# this will also check for no-id-professional as professional
is_paid_seat = any(seat_type in enrollment_mode for seat_type in applicable_seat_types)
def _filter_out_courses_with_enrollments(self, courses):
"""
Removes courses for which the current user already holds an active and applicable enrollment
for one of that course's runs.
return is_paid_seat
Arguments:
courses (list): Containing dicts representing courses in a program
Returns:
A subset of the given list of course dicts
"""
enrollments = self.user.courseenrollment_set.filter(
is_active=True,
mode__in=self.data['applicable_seat_types']
)
course_runs_with_enrollments = set(unicode(enrollment.course_id) for enrollment in enrollments)
courses_without_enrollments = []
for course in courses:
if all(unicode(run['key']) not in course_runs_with_enrollments for run in course['course_runs']):
courses_without_enrollments.append(course)
return courses_without_enrollments
def _collect_one_click_purchase_eligibility_data(self):
"""
Extend the program data with data about learner's eligibility for one click purchase,
discount data of the program and SKUs of seats that should be added to basket.
"""
applicable_seat_types = self.data['applicable_seat_types']
if 'professional' in self.data['applicable_seat_types']:
self.data['applicable_seat_types'].append('no-id-professional')
applicable_seat_types = set(seat for seat in self.data['applicable_seat_types'] if seat != 'credit')
is_learner_eligible_for_one_click_purchase = self.data['is_program_eligible_for_one_click_purchase']
skus = []
bundle_variant = 'full'
if is_learner_eligible_for_one_click_purchase:
for course in self.data['courses']:
add_course_sku = True
course_runs = course.get('course_runs', [])
published_course_runs = filter(lambda run: run['status'] == 'published', course_runs)
if len(published_course_runs) == 1:
for course_run in course_runs:
is_paid_seat = self._check_enrollment_for_user(course_run)
if is_paid_seat:
add_course_sku = False
break
if add_course_sku:
if is_learner_eligible_for_one_click_purchase:
courses = self.data['courses']
if not self.user.is_anonymous():
courses = self._filter_out_courses_with_enrollments(courses)
courses = self._filter_out_courses_with_entitlements(courses)
if len(courses) < len(self.data['courses']):
bundle_variant = 'partial'
for course in courses:
entitlement_product = False
for entitlement in course.get('entitlements', []):
# We add the first entitlement product found with an applicable seat type because, at this time,
# we are assuming that, for any given course, there is at most one paid entitlement available.
if entitlement['mode'] in applicable_seat_types:
skus.append(entitlement['sku'])
entitlement_product = True
break
if not entitlement_product:
course_runs = course.get('course_runs', [])
published_course_runs = [run for run in course_runs if run['status'] == 'published']
if len(published_course_runs) == 1:
for seat in published_course_runs[0]['seats']:
if seat['type'] in applicable_seat_types and seat['sku']:
skus.append(seat['sku'])
break
else:
bundle_variant = 'partial'
else:
# If a course in the program has more than 1 published course run
# learner won't be eligible for a one click purchase.
is_learner_eligible_for_one_click_purchase = False
skus = []
break
# If a course in the program has more than 1 published course run
# learner won't be eligible for a one click purchase.
skus = []
break
if skus:
try:
......@@ -604,7 +646,7 @@ class ProgramMarketingDataExtender(ProgramDataExtender):
def __init__(self, program_data, user):
super(ProgramMarketingDataExtender, self).__init__(program_data, user)
# Aggregate list of instructors for the program
# Aggregate list of instructors for the program keyed by name
self.instructors = []
# Values for programs' price calculation.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment