478 lines
20 KiB
Python
478 lines
20 KiB
Python
"""
|
|
Utilities to facilitate experimentation
|
|
"""
|
|
|
|
|
|
import logging
|
|
from decimal import Decimal
|
|
|
|
from django.utils.timezone import now
|
|
from edx_toggles.toggles import WaffleFlag
|
|
from opaque_keys import InvalidKeyError
|
|
from opaque_keys.edx.keys import CourseKey
|
|
|
|
from common.djangoapps.course_modes.models import CourseMode, format_course_price, get_cosmetic_verified_display_price
|
|
from common.djangoapps.entitlements.models import CourseEntitlement
|
|
from common.djangoapps.student.models import CourseEnrollment
|
|
from lms.djangoapps.commerce.utils import EcommerceService
|
|
from lms.djangoapps.courseware.access import has_staff_access_to_preview_mode
|
|
from lms.djangoapps.courseware.utils import can_show_verified_upgrade, verified_upgrade_deadline_link
|
|
from openedx.core.djangoapps.catalog.utils import get_programs
|
|
from openedx.core.djangoapps.django_comment_common.models import Role
|
|
from openedx.core.djangoapps.schedules.models import Schedule
|
|
from openedx.features.course_duration_limits.access import get_user_course_duration, get_user_course_expiration_date
|
|
from xmodule.partitions.partitions_service import get_all_partitions_for_course, get_user_partition_groups # lint-amnesty, pylint: disable=wrong-import-order
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# TODO: clean up as part of REVEM-199 (START)
|
|
# .. toggle_name: experiments.add_programs
|
|
# .. toggle_implementation: WaffleFlag
|
|
# .. toggle_default: False
|
|
# .. toggle_description: Toggle for adding the current course's program information to user metadata
|
|
# .. toggle_use_cases: temporary
|
|
# .. toggle_creation_date: 2019-2-25
|
|
# .. toggle_target_removal_date: None
|
|
# .. toggle_tickets: REVEM-63, REVEM-198
|
|
# .. toggle_warning: This temporary feature toggle does not have a target removal date.
|
|
PROGRAM_INFO_FLAG = WaffleFlag(
|
|
'experiments.add_programs',
|
|
__name__,
|
|
)
|
|
|
|
# .. toggle_name: experiments.add_dashboard_info
|
|
# .. toggle_implementation: WaffleFlag
|
|
# .. toggle_default: False
|
|
# .. toggle_description: Toggle for adding info about each course to the dashboard metadata
|
|
# .. toggle_use_cases: temporary
|
|
# .. toggle_creation_date: 2019-3-28
|
|
# .. toggle_target_removal_date: None
|
|
# .. toggle_tickets: REVEM-118
|
|
# .. toggle_warning: This temporary feature toggle does not have a target removal date.
|
|
DASHBOARD_INFO_FLAG = WaffleFlag('experiments.add_dashboard_info', __name__)
|
|
# TODO END: clean up as part of REVEM-199 (End)
|
|
|
|
# TODO: Clean up as part of REV-1205 (START)
|
|
# .. toggle_name: experiments.add_upsell_tracking
|
|
# .. toggle_implementation: WaffleFlag
|
|
# .. toggle_default: False
|
|
# .. toggle_description: Make sure upsell tracking JS works as expected.
|
|
# .. toggle_use_cases: temporary
|
|
# .. toggle_creation_date: 2020-7-7
|
|
# .. toggle_target_removal_date: None
|
|
# .. toggle_tickets: REV-1205
|
|
# .. toggle_warning: This temporary feature toggle does not have a target removal date.
|
|
UPSELL_TRACKING_FLAG = WaffleFlag(
|
|
'experiments.add_upsell_tracking',
|
|
__name__,
|
|
)
|
|
# TODO END: Clean up as part of REV-1205 (End)
|
|
|
|
|
|
def check_and_get_upgrade_link_and_date(user, enrollment=None, course=None):
|
|
"""
|
|
For an authenticated user, return a link to allow them to upgrade
|
|
in the specified course.
|
|
|
|
Returns the upgrade link and upgrade deadline for a user in a given course given
|
|
that the user is within the window to upgrade defined by our dynamic pacing feature;
|
|
otherwise, returns None for both the link and date.
|
|
"""
|
|
if enrollment is None and course is None:
|
|
logger.warning('Must specify either an enrollment or a course')
|
|
return (None, None, None)
|
|
|
|
if enrollment:
|
|
if course and enrollment.course_id != course.id:
|
|
logger.warning('{} refers to a different course than {} which was supplied. Enrollment course id={}, '
|
|
'repr={!r}, deprecated={}. Course id={}, repr={!r}, deprecated={}.'
|
|
.format(enrollment,
|
|
course,
|
|
enrollment.course_id,
|
|
enrollment.course_id,
|
|
enrollment.course_id.deprecated,
|
|
course.id,
|
|
course.id,
|
|
course.id.deprecated
|
|
)
|
|
)
|
|
return (None, None, None)
|
|
|
|
if enrollment.user_id != user.id:
|
|
logger.warning('{} refers to a different user than {} which was supplied. '
|
|
'Enrollment user id={}, repr={!r}. '
|
|
'User id={}, repr={!r}.'.format(enrollment,
|
|
user,
|
|
enrollment.user_id,
|
|
enrollment.user_id,
|
|
user.id,
|
|
user.id,
|
|
)
|
|
)
|
|
return (None, None, None)
|
|
|
|
if enrollment is None:
|
|
enrollment = CourseEnrollment.get_enrollment(user, course.id)
|
|
if enrollment is None:
|
|
return (None, None, None)
|
|
|
|
if user.is_authenticated and can_show_verified_upgrade(user, enrollment, course):
|
|
return (
|
|
verified_upgrade_deadline_link(user, enrollment.course),
|
|
enrollment.upgrade_deadline,
|
|
enrollment.course_upgrade_deadline,
|
|
)
|
|
|
|
return (None, None, enrollment.course_upgrade_deadline)
|
|
|
|
|
|
# TODO: clean up as part of REVEM-199 (START)
|
|
def get_program_price_and_skus(courses):
|
|
"""
|
|
Get the total program price and purchase skus from these courses in the program
|
|
"""
|
|
program_price = 0
|
|
skus = []
|
|
|
|
for course in courses:
|
|
course_price, course_sku = get_course_entitlement_price_and_sku(course)
|
|
if course_price is not None and course_sku is not None:
|
|
program_price = Decimal(program_price) + Decimal(course_price)
|
|
skus.append(course_sku)
|
|
|
|
if program_price <= 0:
|
|
program_price = None
|
|
skus = None
|
|
else:
|
|
program_price = format_course_price(program_price)
|
|
program_price = str(program_price)
|
|
|
|
return program_price, skus
|
|
|
|
|
|
def get_course_entitlement_price_and_sku(course):
|
|
"""
|
|
Get the entitlement price and sku from this course.
|
|
Try to get them from the first non-expired, verified entitlement that has a price and a sku. If that doesn't work,
|
|
fall back to the first non-expired, verified course run that has a price and a sku.
|
|
"""
|
|
for entitlement in course.get('entitlements', []):
|
|
if entitlement.get('mode') == 'verified' and entitlement['price'] and entitlement['sku']:
|
|
expires = entitlement.get('expires')
|
|
if not expires or expires > now():
|
|
return entitlement['price'], entitlement['sku']
|
|
|
|
course_runs = course.get('course_runs', [])
|
|
published_course_runs = [run for run in course_runs if run['status'] == 'published']
|
|
for published_course_run in published_course_runs:
|
|
for seat in published_course_run['seats']:
|
|
if seat.get('type') == 'verified' and seat['price'] and seat['sku']:
|
|
price = Decimal(seat.get('price'))
|
|
return price, seat.get('sku')
|
|
|
|
return None, None
|
|
|
|
|
|
def get_unenrolled_courses(courses, user_enrollments):
|
|
"""
|
|
Given a list of courses and a list of user enrollments, return the courses in which the user is not enrolled.
|
|
Depending on the enrollments that are passed in, this method can be used to determine the courses in a program in
|
|
which the user has not yet enrolled or the courses in a program for which the user has not yet purchased a
|
|
certificate.
|
|
"""
|
|
# Get the enrollment course ids here, so we don't need to loop through them for every course run
|
|
enrollment_course_ids = {enrollment.course_id for enrollment in user_enrollments}
|
|
unenrolled_courses = []
|
|
|
|
for course in courses:
|
|
if not is_enrolled_in_course(course, enrollment_course_ids):
|
|
unenrolled_courses.append(course)
|
|
return unenrolled_courses
|
|
|
|
|
|
def is_enrolled_in_all_courses(courses, user_enrollments):
|
|
"""
|
|
Determine if the user is enrolled in all of the courses
|
|
"""
|
|
# Get the enrollment course ids here, so we don't need to loop through them for every course run
|
|
enrollment_course_ids = {enrollment.course_id for enrollment in user_enrollments}
|
|
|
|
for course in courses:
|
|
if not is_enrolled_in_course(course, enrollment_course_ids):
|
|
# User is not enrolled in this course, meaning they are not enrolled in all courses in the program
|
|
return False
|
|
# User is enrolled in all courses in the program
|
|
return True
|
|
|
|
|
|
def is_enrolled_in_course(course, enrollment_course_ids):
|
|
"""
|
|
Determine if the user is enrolled in this course
|
|
"""
|
|
course_runs = course.get('course_runs')
|
|
if course_runs:
|
|
for course_run in course_runs:
|
|
if is_enrolled_in_course_run(course_run, enrollment_course_ids):
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_enrolled_in_course_run(course_run, enrollment_course_ids):
|
|
"""
|
|
Determine if the user is enrolled in this course run
|
|
"""
|
|
key = None
|
|
try:
|
|
key = course_run.get('key')
|
|
course_run_key = CourseKey.from_string(key)
|
|
return course_run_key in enrollment_course_ids
|
|
except InvalidKeyError:
|
|
logger.warning(
|
|
f'Unable to determine if user was enrolled since the course key {key} is invalid'
|
|
)
|
|
return False # Invalid course run key. Assume user is not enrolled.
|
|
|
|
|
|
def get_dashboard_course_info(user, dashboard_enrollments):
|
|
"""
|
|
Given a list of enrollments shown on the dashboard, return a dict of course ids and experiment info for that course
|
|
"""
|
|
course_info = None
|
|
if DASHBOARD_INFO_FLAG.is_enabled():
|
|
# Get the enrollments here since the dashboard filters out those with completed entitlements
|
|
user_enrollments = CourseEnrollment.objects.select_related('course').filter(user_id=user.id)
|
|
|
|
course_info = {
|
|
str(dashboard_enrollment.course): get_base_experiment_metadata_context(dashboard_enrollment.course,
|
|
user,
|
|
dashboard_enrollment,
|
|
user_enrollments)
|
|
for dashboard_enrollment in dashboard_enrollments
|
|
}
|
|
return course_info
|
|
# TODO: clean up as part of REVEM-199 (END)
|
|
|
|
|
|
def get_experiment_user_metadata_context(course, user):
|
|
"""
|
|
Return a context dictionary with the keys used for Optimizely experiments, exposed via user_metadata.html:
|
|
view from the DOM in those calling views using: JSON.parse($("#user-metadata").text());
|
|
Most views call this function with both parameters, but student dashboard has only a user
|
|
"""
|
|
enrollment = None
|
|
# TODO: clean up as part of REVO-28 (START)
|
|
user_enrollments = None
|
|
audit_enrollments = None # lint-amnesty, pylint: disable=unused-variable
|
|
has_non_audit_enrollments = False
|
|
context = {}
|
|
if course is not None:
|
|
try:
|
|
user_enrollments = CourseEnrollment.objects.select_related('course', 'schedule').filter(user_id=user.id)
|
|
has_non_audit_enrollments = user_enrollments.exclude(mode__in=CourseMode.UPSELL_TO_VERIFIED_MODES).exists()
|
|
# TODO: clean up as part of REVO-28 (END)
|
|
enrollment = CourseEnrollment.objects.select_related(
|
|
'course', 'schedule'
|
|
).get(user_id=user.id, course_id=course.id)
|
|
except CourseEnrollment.DoesNotExist:
|
|
pass # Not enrolled, use the default values
|
|
|
|
has_entitlements = False
|
|
if user.is_authenticated:
|
|
has_entitlements = CourseEntitlement.objects.filter(user=user).exists()
|
|
|
|
context = get_base_experiment_metadata_context(course, user, enrollment, user_enrollments)
|
|
has_staff_access = has_staff_access_to_preview_mode(user, course.id)
|
|
forum_roles = []
|
|
if user.is_authenticated:
|
|
forum_roles = list(Role.objects.filter(users=user, course_id=course.id).values_list('name').distinct())
|
|
|
|
# get user partition data
|
|
if user.is_authenticated:
|
|
partition_groups = get_all_partitions_for_course(course)
|
|
user_partitions = get_user_partition_groups(course.id, partition_groups, user, 'name')
|
|
else:
|
|
user_partitions = {}
|
|
|
|
# TODO: clean up as part of REVO-28 (START)
|
|
context['has_non_audit_enrollments'] = has_non_audit_enrollments or has_entitlements
|
|
# TODO: clean up as part of REVO-28 (END)
|
|
context['has_staff_access'] = has_staff_access
|
|
context['forum_roles'] = forum_roles
|
|
context['partition_groups'] = user_partitions
|
|
|
|
user_metadata = {
|
|
key: context.get(key)
|
|
for key in (
|
|
'username',
|
|
'user_id',
|
|
'course_id',
|
|
'course_display_name',
|
|
'enrollment_mode',
|
|
'upgrade_link',
|
|
'upgrade_price',
|
|
'audit_access_deadline',
|
|
'course_duration',
|
|
'pacing_type',
|
|
'has_staff_access',
|
|
'forum_roles',
|
|
'partition_groups',
|
|
# TODO: clean up as part of REVO-28 (START)
|
|
'has_non_audit_enrollments',
|
|
# TODO: clean up as part of REVO-28 (END)
|
|
# TODO: clean up as part of REVEM-199 (START)
|
|
'program_key_fields',
|
|
# TODO: clean up as part of REVEM-199 (END)
|
|
)
|
|
}
|
|
|
|
if user:
|
|
user_metadata['username'] = user.username
|
|
user_metadata['user_id'] = user.id
|
|
if hasattr(user, 'email'):
|
|
user_metadata['email'] = user.email
|
|
|
|
for datekey in (
|
|
'schedule_start',
|
|
'enrollment_time',
|
|
'course_start',
|
|
'course_end',
|
|
'dynamic_upgrade_deadline',
|
|
'course_upgrade_deadline',
|
|
'audit_access_deadline',
|
|
):
|
|
user_metadata[datekey] = (
|
|
context.get(datekey).isoformat() if context.get(datekey) else None
|
|
)
|
|
|
|
for timedeltakey in (
|
|
'course_duration',
|
|
):
|
|
user_metadata[timedeltakey] = (
|
|
context.get(timedeltakey).total_seconds() if context.get(timedeltakey) else None
|
|
)
|
|
|
|
course_key = context.get('course_key')
|
|
if course and not course_key:
|
|
course_key = course.id
|
|
|
|
if course_key:
|
|
if isinstance(course_key, CourseKey):
|
|
user_metadata['course_key_fields'] = {
|
|
'org': course_key.org,
|
|
'course': course_key.course,
|
|
'run': course_key.run,
|
|
}
|
|
|
|
if not context.get('course_id'):
|
|
user_metadata['course_id'] = str(course_key)
|
|
elif isinstance(course_key, str):
|
|
user_metadata['course_id'] = course_key
|
|
|
|
context['user_metadata'] = user_metadata
|
|
return context
|
|
|
|
|
|
def get_base_experiment_metadata_context(course, user, enrollment, user_enrollments):
|
|
"""
|
|
Return a context dictionary with the keys used by dashboard_metadata.html and user_metadata.html
|
|
"""
|
|
enrollment_mode = None
|
|
enrollment_time = None
|
|
# TODO: clean up as part of REVEM-199 (START)
|
|
program_key = get_program_context(course, user_enrollments)
|
|
# TODO: clean up as part of REVEM-199 (END)
|
|
schedule_start = None
|
|
if enrollment and enrollment.is_active:
|
|
enrollment_mode = enrollment.mode
|
|
enrollment_time = enrollment.created
|
|
|
|
try:
|
|
schedule_start = enrollment.schedule.start_date
|
|
except Schedule.DoesNotExist:
|
|
pass
|
|
|
|
# upgrade_link, dynamic_upgrade_deadline and course_upgrade_deadline should be None
|
|
# if user has passed their dynamic pacing deadline.
|
|
upgrade_link, dynamic_upgrade_deadline, course_upgrade_deadline = check_and_get_upgrade_link_and_date(
|
|
user, enrollment, course
|
|
)
|
|
|
|
duration = get_user_course_duration(user, course)
|
|
deadline = duration and get_user_course_expiration_date(user, course)
|
|
|
|
return {
|
|
'upgrade_link': upgrade_link,
|
|
'upgrade_price': str(get_cosmetic_verified_display_price(course)),
|
|
'enrollment_mode': enrollment_mode,
|
|
'enrollment_time': enrollment_time,
|
|
'schedule_start': schedule_start,
|
|
'pacing_type': 'self_paced' if course.self_paced else 'instructor_paced',
|
|
'dynamic_upgrade_deadline': dynamic_upgrade_deadline,
|
|
'course_upgrade_deadline': course_upgrade_deadline,
|
|
'audit_access_deadline': deadline,
|
|
'course_duration': duration,
|
|
'course_key': course.id,
|
|
'course_display_name': course.display_name_with_default,
|
|
'course_start': course.start,
|
|
'course_end': course.end,
|
|
# TODO: clean up as part of REVEM-199 (START)
|
|
'program_key_fields': program_key,
|
|
# TODO: clean up as part of REVEM-199 (END)
|
|
}
|
|
|
|
|
|
# TODO: clean up as part of REVEM-199 (START)
|
|
def get_program_context(course, user_enrollments):
|
|
"""
|
|
Return a context dictionary with program information.
|
|
"""
|
|
program_key = None
|
|
non_audit_enrollments = user_enrollments.exclude(mode__in=CourseMode.UPSELL_TO_VERIFIED_MODES)
|
|
|
|
if PROGRAM_INFO_FLAG.is_enabled():
|
|
programs = get_programs(course=course.id)
|
|
if programs:
|
|
# A course can be in multiple programs, but we're just grabbing the first one
|
|
program = programs[0]
|
|
complete_enrollment = False
|
|
has_courses_left_to_purchase = False
|
|
total_courses = None
|
|
courses = program.get('courses')
|
|
courses_left_to_purchase_price = None
|
|
courses_left_to_purchase_url = None
|
|
program_uuid = program.get('uuid')
|
|
is_eligible_for_one_click_purchase = program.get('is_program_eligible_for_one_click_purchase')
|
|
if courses is not None:
|
|
total_courses = len(courses)
|
|
complete_enrollment = is_enrolled_in_all_courses(courses, user_enrollments)
|
|
|
|
# Get the price and purchase URL of the program courses the user has yet to purchase. Say a
|
|
# program has 3 courses (A, B and C), and the user previously purchased a certificate for A.
|
|
# The user is enrolled in audit mode for B. The "left to purchase price" should be the price of
|
|
# B+C.
|
|
courses_left_to_purchase = get_unenrolled_courses(courses, non_audit_enrollments)
|
|
if courses_left_to_purchase:
|
|
has_courses_left_to_purchase = True
|
|
if courses_left_to_purchase and is_eligible_for_one_click_purchase:
|
|
courses_left_to_purchase_price, courses_left_to_purchase_skus = \
|
|
get_program_price_and_skus(courses_left_to_purchase)
|
|
if courses_left_to_purchase_skus:
|
|
courses_left_to_purchase_url = EcommerceService().get_checkout_page_url(
|
|
*courses_left_to_purchase_skus, program_uuid=program_uuid)
|
|
|
|
program_key = {
|
|
'uuid': program_uuid,
|
|
'title': program.get('title'),
|
|
'marketing_url': program.get('marketing_url'),
|
|
'status': program.get('status'),
|
|
'is_eligible_for_one_click_purchase': is_eligible_for_one_click_purchase,
|
|
'total_courses': total_courses,
|
|
'complete_enrollment': complete_enrollment,
|
|
'has_courses_left_to_purchase': has_courses_left_to_purchase,
|
|
'courses_left_to_purchase_price': courses_left_to_purchase_price,
|
|
'courses_left_to_purchase_url': courses_left_to_purchase_url,
|
|
}
|
|
return program_key
|
|
# TODO: clean up as part of REVEM-199 (START)
|