Skip to content

Commit

Permalink
ENT-4095: Handle coupon expiration date scenario in LMS (#27704)
Browse files Browse the repository at this point in the history
  • Loading branch information
sameenfatima78 authored May 24, 2021
1 parent 2e01dc0 commit 0be941b
Show file tree
Hide file tree
Showing 11 changed files with 195 additions and 33 deletions.
88 changes: 67 additions & 21 deletions common/djangoapps/student/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ugettext_noop
from django_countries.fields import CountryField
from edx_django_utils.cache import RequestCache
from edx_django_utils.cache import RequestCache, TieredCache, get_cache_key
from edx_django_utils import monitoring
from edx_rest_api_client.exceptions import SlumberBaseException
from eventtracking import tracker
Expand Down Expand Up @@ -1890,29 +1890,75 @@ def refundable(self, user_already_has_certs_for=None):
def refund_cutoff_date(self):
""" Calculate and return the refund window end date. """
# NOTE: This is here to avoid circular references
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, ECOMMERCE_DATE_FORMAT

from openedx.core.djangoapps.commerce.utils import ECOMMERCE_DATE_FORMAT
date_placed = self.get_order_attribute_value('date_placed')

if not date_placed:
order_number = self.get_order_attribute_value('order_number')
if not order_number:
return None

date_placed = self.get_order_attribute_from_ecommerce('date_placed')
if not date_placed:
return None

# also save the attribute so that we don't need to call ecommerce again.
username = self.user.username
enrollment_attributes = get_enrollment_attributes(username, str(self.course_id))
enrollment_attributes.append(
{
"namespace": "order",
"name": "date_placed",
"value": date_placed,
}
)
set_enrollment_attributes(username, str(self.course_id), enrollment_attributes)

refund_window_start_date = max(
datetime.strptime(date_placed, ECOMMERCE_DATE_FORMAT),
self.course_overview.start.replace(tzinfo=None)
)

return refund_window_start_date.replace(tzinfo=UTC) + EnrollmentRefundConfiguration.current().refund_window

def is_order_voucher_refundable(self):
""" Checks if the coupon batch expiration date has passed to determine whether order voucher is refundable. """
from openedx.core.djangoapps.commerce.utils import ECOMMERCE_DATE_FORMAT
vouchers = self.get_order_attribute_from_ecommerce('vouchers')
if not vouchers:
return False
voucher_end_datetime_str = vouchers[0]['end_datetime']
voucher_expiration_date = datetime.strptime(voucher_end_datetime_str, ECOMMERCE_DATE_FORMAT).replace(tzinfo=UTC)
return datetime.now(UTC) < voucher_expiration_date

def get_order_attribute_from_ecommerce(self, attribute_name):
"""
Fetches the order details from ecommerce to return the value of the attribute passed as argument.
Arguments:
attribute_name (str): The name of the attribute that you want to fetch from response e:g 'number' or
'vouchers', etc.
Returns:
(str | array | None): Returns the attribute value if it exists, returns None if the order doesn't exist or
attribute doesn't exist in the response.
"""

# NOTE: This is here to avoid circular references
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
order_number = self.get_order_attribute_value('order_number')
if not order_number:
return None

# check if response is already cached
cache_key = get_cache_key(user_id=self.user.id, order_number=order_number)
cached_response = TieredCache.get_cached_response(cache_key)
if cached_response.is_found:
order = cached_response.value
else:
try:
# response is not cached, so make a call to ecommerce to fetch order details
order = ecommerce_api_client(self.user).orders(order_number).get()
date_placed = order['date_placed']
# also save the attribute so that we don't need to call ecommerce again.
username = self.user.username
enrollment_attributes = get_enrollment_attributes(username, str(self.course_id))
enrollment_attributes.append(
{
"namespace": "order",
"name": "date_placed",
"value": date_placed,
}
)
set_enrollment_attributes(username, str(self.course_id), enrollment_attributes)
except HttpClientError:
log.warning(
"Encountered HttpClientError while getting order details from ecommerce. "
Expand All @@ -1931,12 +1977,12 @@ def refund_cutoff_date(self):
"Order={number} and user {user}".format(number=order_number, user=self.user.id))
return None

refund_window_start_date = max(
datetime.strptime(date_placed, ECOMMERCE_DATE_FORMAT),
self.course_overview.start.replace(tzinfo=None)
)

return refund_window_start_date.replace(tzinfo=UTC) + EnrollmentRefundConfiguration.current().refund_window
cache_time_out = getattr(settings, 'ECOMMERCE_ORDERS_API_CACHE_TIMEOUT', 3600)
TieredCache.set_all_tiers(cache_key, order, cache_time_out)
try:
return order[attribute_name]
except KeyError:
return None

def get_order_attribute_value(self, attr_name):
""" Get and return course enrollment order attribute's value."""
Expand Down
84 changes: 83 additions & 1 deletion common/djangoapps/student/tests/test_refunds.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Tests for enrollment refund capabilities.
"""


import json
import logging
import unittest
from datetime import datetime, timedelta
Expand All @@ -17,6 +17,7 @@
from django.test.client import Client
from django.test.utils import override_settings
from django.urls import reverse
from edx_django_utils.cache import TieredCache, get_cache_key

# These imports refer to lms djangoapps.
# Their testcases are only run under lms.
Expand Down Expand Up @@ -165,10 +166,91 @@ def test_refund_cutoff_date(self, order_date_delta, course_start_delta, expected

assert expected_date_placed_attr in CourseEnrollmentAttribute.get_enrollment_attributes(self.enrollment)

@ddt.data(
(datetime.now(pytz.UTC) + timedelta(days=1), True),
(datetime.now(pytz.UTC) - timedelta(days=1), False),
(datetime.now(pytz.UTC) - timedelta(minutes=5), False),
)
@ddt.unpack
@httpretty.activate
@override_settings(ECOMMERCE_API_URL=TEST_API_URL)
def test_is_order_voucher_refundable(self, voucher_expiration_date, expected):
"""
Assert that the correct value is returned based on voucher expiration date.
"""
voucher_expiration_date_str = voucher_expiration_date.strftime(ECOMMERCE_DATE_FORMAT)
response = json.dumps({"vouchers": [{"end_datetime": voucher_expiration_date_str}]})
httpretty.register_uri(
httpretty.GET,
f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
status=200, body=response,
adding_headers={'Content-Type': JSON}
)

self.enrollment.attributes.create(
enrollment=self.enrollment,
namespace='order',
name='order_number',
value=self.ORDER_NUMBER
)
assert self.enrollment.is_order_voucher_refundable() == expected

def test_refund_cutoff_date_no_attributes(self):
""" Assert that the None is returned when no order number attribute is found."""
assert self.enrollment.refund_cutoff_date() is None

@httpretty.activate
@override_settings(ECOMMERCE_API_URL=TEST_API_URL)
def test_is_order_voucher_refundable_no_attributes(self, ):
""" Assert that False is returned when no order number or vouchers attribute is found in response."""
# no order number attribute
assert self.enrollment.is_order_voucher_refundable() is False

# no voucher information in orders api response
response = json.dumps({"vouchers": []})
httpretty.register_uri(
httpretty.GET,
f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
status=200, body=response,
adding_headers={'Content-Type': JSON}
)

self.enrollment.attributes.create(
enrollment=self.enrollment,
namespace='order',
name='order_number',
value=self.ORDER_NUMBER
)
assert self.enrollment.is_order_voucher_refundable() is False

response = json.dumps({"vouchers": None})
httpretty.register_uri(
httpretty.GET,
f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
status=200, body=response,
adding_headers={'Content-Type': JSON}
)
assert self.enrollment.is_order_voucher_refundable() is False

@patch('openedx.core.djangoapps.commerce.utils.ecommerce_api_client')
def test_get_order_attribute_from_ecommerce(self, mock_ecommerce_api_client):
"""
Assert that the get_order_attribute_from_ecommerce method returns order details if it's already cached,
without calling ecommerce.
"""
order_details = {"number": self.ORDER_NUMBER, "vouchers": [{"end_datetime": '2025-09-25T00:00:00Z'}]}
cache_key = get_cache_key(user_id=self.user.id, order_number=self.ORDER_NUMBER)
TieredCache.set_all_tiers(cache_key, order_details, 60)

self.enrollment.attributes.create(
enrollment=self.enrollment,
namespace='order',
name='order_number',
value=self.ORDER_NUMBER
)
assert self.enrollment.get_order_attribute_from_ecommerce("vouchers") == order_details["vouchers"]
mock_ecommerce_api_client.assert_not_called()

@patch('openedx.core.djangoapps.commerce.utils.ecommerce_api_client')
def test_refund_cutoff_date_with_date_placed_attr(self, mock_ecommerce_api_client):
"""
Expand Down
7 changes: 7 additions & 0 deletions common/djangoapps/student/views/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,12 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem
if enrollment.is_paid_course()
)

# Checks if a course enrollment redeemed using a voucher is refundable
enrolled_courses_voucher_refundable = frozenset(
enrollment.course_id for enrollment in course_enrollments
if enrollment.is_order_voucher_refundable()
)

# If there are *any* denied reverifications that have not been toggled off,
# we'll display the banner
denied_banner = any(item.display for item in reverifications["denied"])
Expand Down Expand Up @@ -775,6 +781,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem
'logout_url': reverse('logout'),
'platform_name': platform_name,
'enrolled_courses_either_paid': enrolled_courses_either_paid,
'enrolled_courses_voucher_refundable': enrolled_courses_voucher_refundable,
'provider_states': [],
'courses_requirements_not_met': courses_requirements_not_met,
'nav_hidden': True,
Expand Down
1 change: 1 addition & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3936,6 +3936,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
ECOMMERCE_PUBLIC_URL_ROOT = 'http://localhost:8002'
ECOMMERCE_API_URL = 'http://localhost:8002/api/v2'
ECOMMERCE_API_TIMEOUT = 5
ECOMMERCE_ORDERS_API_CACHE_TIMEOUT = 3600
ECOMMERCE_SERVICE_WORKER_USERNAME = 'ecommerce_worker'
ECOMMERCE_API_SIGNING_KEY = 'SET-ME-PLEASE'

Expand Down
3 changes: 3 additions & 0 deletions lms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,9 @@ def get_env_setting(setting):
# Enrollment API Cache Timeout
ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT = ENV_TOKENS.get('ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT', 60)

# Ecommerce Orders API Cache Timeout
ECOMMERCE_ORDERS_API_CACHE_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_ORDERS_API_CACHE_TIMEOUT', 3600)

if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or \
FEATURES.get('ENABLE_DASHBOARD_SEARCH') or \
FEATURES.get('ENABLE_COURSE_DISCOVERY') or \
Expand Down
8 changes: 6 additions & 2 deletions lms/static/js/dashboard/legacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
return properties;
}

function setDialogAttributes(isPaidCourse, certNameLong,
function setDialogAttributes(isPaidCourse, isCourseVoucherRefundable, certNameLong,
courseNumber, courseName, enrollmentMode, showRefundOption, courseKey) {
var diagAttr = {};

Expand All @@ -99,6 +99,9 @@
} else if (enrollmentMode !== 'verified') {
diagAttr['data-track-info'] = gettext('Are you sure you want to unenroll from {courseName} ' +
'({courseNumber})?');
} else if (showRefundOption && !isCourseVoucherRefundable) {
diagAttr['data-track-info'] = gettext('Are you sure you want to unenroll from the verified ' +
'{certNameLong} track of {courseName} ({courseNumber})?');
} else if (showRefundOption) {
diagAttr['data-track-info'] = gettext('Are you sure you want to unenroll from the verified ' +
'{certNameLong} track of {courseName} ({courseNumber})?');
Expand Down Expand Up @@ -134,6 +137,7 @@
});
$('.action-unenroll').click(function(event) {
var isPaidCourse = $(event.target).data('course-is-paid-course') === 'True',
isCourseVoucherRefundable = $(event.target).data('is-course-voucher-refundable') === 'True',
certNameLong = $(event.target).data('course-cert-name-long'),
enrollmentMode = $(event.target).data('course-enrollment-mode'),
courseNumber = $(event.target).data('course-number'),
Expand All @@ -149,7 +153,7 @@
});
request.success(function(data, textStatus, xhr) {
if (xhr.status === 200) {
dialogMessageAttr = setDialogAttributes(isPaidCourse, certNameLong,
dialogMessageAttr = setDialogAttributes(isPaidCourse, isCourseVoucherRefundable, certNameLong,
courseNumber, courseName, enrollmentMode, data.course_refundable_status, courseKey);

$('#track-info').empty();
Expand Down
3 changes: 2 additions & 1 deletion lms/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,14 @@
credit_status = credit_statuses.get(session_id)
course_mode_info = all_course_modes.get(session_id)
is_paid_course = True if entitlement else (session_id in enrolled_courses_either_paid)
is_course_voucher_refundable = (session_id in enrolled_courses_voucher_refundable)
course_verification_status = verification_status_by_course.get(session_id, {})
course_requirements = courses_requirements_not_met.get(session_id)
related_programs = inverted_programs.get(six.text_type(entitlement.course_uuid if is_unfulfilled_entitlement else session_id))
show_consent_link = (session_id in consent_required_courses)
resume_button_url = resume_button_urls[dashboard_index]
%>
<%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, entitlement_expiration_date=entitlement_expiration_date, entitlement_expired_at=entitlement_expired_at, show_courseware_link=show_courseware_link, cert_status=cert_status, can_refund_entitlement=can_refund_entitlement, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name, resume_button_url=resume_button_url, partner_managed_enrollment=partner_managed_enrollment' />
<%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, entitlement_expiration_date=entitlement_expiration_date, entitlement_expired_at=entitlement_expired_at, show_courseware_link=show_courseware_link, cert_status=cert_status, can_refund_entitlement=can_refund_entitlement, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_voucher_refundable=is_course_voucher_refundable, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name, resume_button_url=resume_button_url, partner_managed_enrollment=partner_managed_enrollment' />
% endfor
% if show_load_all_courses_link:
<br/>
Expand Down
3 changes: 2 additions & 1 deletion lms/templates/dashboard/_dashboard_course_listing.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<%page args="course_overview, enrollment, entitlement, entitlement_session, course_card_index, is_unfulfilled_entitlement, is_fulfilled_entitlement, entitlement_available_sessions, entitlement_expiration_date, entitlement_expired_at, show_courseware_link, cert_status, can_refund_entitlement, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name, resume_button_url, partner_managed_enrollment" expression_filter="h"/>
<%page args="course_overview, enrollment, entitlement, entitlement_session, course_card_index, is_unfulfilled_entitlement, is_fulfilled_entitlement, entitlement_available_sessions, entitlement_expiration_date, entitlement_expired_at, show_courseware_link, cert_status, can_refund_entitlement, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_voucher_refundable, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name, resume_button_url, partner_managed_enrollment" expression_filter="h"/>

<%!
import datetime
Expand Down Expand Up @@ -266,6 +266,7 @@ <h3 class="course-title" id="course-title-${enrollment.course_id}">
data-dashboard-index="${dashboard_index}"
data-course-refund-url="${course_refund_url}"
data-course-is-paid-course="${is_paid_course}"
data-is-course-voucher-refundable="${is_course_voucher_refundable}"
data-course-cert-name-long="${cert_name_long}"
data-course-enrollment-mode="${enrollment.mode}">
${_('Unenroll')}
Expand Down
2 changes: 2 additions & 0 deletions openedx/features/enterprise_support/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ def refund_order_voucher(sender, course_enrollment, skip_refund=False, **kwargs)
return
if not course_enrollment.refundable():
return
if not course_enrollment.is_order_voucher_refundable():
return
if not EnterpriseCourseEnrollment.objects.filter(
enterprise_customer_user__user_id=course_enrollment.user_id,
course_id=str(course_enrollment.course.id)
Expand Down
Loading

0 comments on commit 0be941b

Please sign in to comment.