Skip to content
Snippets Groups Projects
Unverified Commit 9f2a72ad authored by Sameen Fatima's avatar Sameen Fatima Committed by GitHub
Browse files

ENT-4095: Handle coupon expiration date scenario in LMS (#27539)

parent a3d1d1b8
No related branches found
No related tags found
No related merge requests found
......@@ -43,7 +43,7 @@ from django.utils.functional import cached_property
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
......@@ -1890,8 +1890,7 @@ class CourseEnrollment(models.Model):
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:
......@@ -1899,20 +1898,67 @@ class CourseEnrollment(models.Model):
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. "
......@@ -1931,12 +1977,12 @@ class CourseEnrollment(models.Model):
"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."""
......
......@@ -2,7 +2,7 @@
Tests for enrollment refund capabilities.
"""
import json
import logging
import unittest
from datetime import datetime, timedelta
......@@ -17,6 +17,7 @@ from django.conf import settings
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.
......@@ -165,10 +166,91 @@ class RefundableTest(SharedModuleStoreTestCase):
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):
"""
......
......@@ -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"])
......@@ -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,
......
......@@ -3936,6 +3936,7 @@ SOCIAL_PLATFORMS = {
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'
......
......@@ -722,6 +722,9 @@ DEFAULT_MOBILE_AVAILABLE = ENV_TOKENS.get(
# 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 \
......
......@@ -84,7 +84,7 @@
return properties;
}
function setDialogAttributes(isPaidCourse, certNameLong,
function setDialogAttributes(isPaidCourse, isCourseVoucherRefundable, certNameLong,
courseNumber, courseName, enrollmentMode, showRefundOption, courseKey) {
var diagAttr = {};
......@@ -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})?');
......@@ -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'),
......@@ -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();
......
......@@ -210,13 +210,14 @@ from common.djangoapps.student.models import CourseEnrollment
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/>
......
<%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
......@@ -266,6 +266,7 @@ from lms.djangoapps.experiments.utils import UPSELL_TRACKING_FLAG
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')}
......
......@@ -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)
......
......@@ -133,28 +133,42 @@ class EnterpriseSupportSignals(SharedModuleStoreTestCase):
return enrollment
@patch('common.djangoapps.student.models.CourseEnrollment.is_order_voucher_refundable')
@ddt.data(
(True, True, 2, False), # test if skip_refund
(False, True, 20, False), # test refundable time passed
(False, False, 2, False), # test not enterprise enrollment
(False, True, 2, True), # success: no skip_refund, is enterprise enrollment and still in refundable window.
(True, True, 2, True, False), # test if skip_refund
(False, True, 20, True, False), # test refundable time passed
(False, False, 2, True, False), # test not enterprise enrollment
(False, True, 2, False, False), # test order voucher expiration date has already passed
(False, True, 2, True, True), # success: no skip_refund, is enterprise enrollment, coupon voucher is refundable
# and is still in refundable window.
)
@ddt.unpack
def test_refund_order_voucher(self, skip_refund, enterprise_enrollment_exists, no_of_days_placed, api_called):
def test_refund_order_voucher(
self,
skip_refund,
enterprise_enrollment_exists,
no_of_days_placed,
order_voucher_refundable,
api_called,
mock_is_order_voucher_refundable
):
"""Test refund_order_voucher signal"""
mock_is_order_voucher_refundable.return_value = order_voucher_refundable
enrollment = self._create_enrollment_to_refund(no_of_days_placed, enterprise_enrollment_exists)
with patch('openedx.features.enterprise_support.signals.ecommerce_api_client') as mock_ecommerce_api_client:
enrollment.update_enrollment(is_active=False, skip_refund=skip_refund)
assert mock_ecommerce_api_client.called == api_called
@patch('common.djangoapps.student.models.CourseEnrollment.is_order_voucher_refundable')
@ddt.data(
(HttpClientError, 'INFO'),
(HttpServerError, 'ERROR'),
(Exception, 'ERROR'),
)
@ddt.unpack
def test_refund_order_voucher_with_client_errors(self, mock_error, log_level):
def test_refund_order_voucher_with_client_errors(self, mock_error, log_level, mock_is_order_voucher_refundable):
"""Test refund_order_voucher signal client_error"""
mock_is_order_voucher_refundable.return_value = True
enrollment = self._create_enrollment_to_refund()
with patch('openedx.features.enterprise_support.signals.ecommerce_api_client') as mock_ecommerce_api_client:
client_instance = mock_ecommerce_api_client.return_value
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment