diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index bde8b1e0626c103ddbcc5fc697ac38170d1f505e..48b05287f5befb1865da6e769bc14c00a2796402 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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.""" diff --git a/common/djangoapps/student/tests/test_refunds.py b/common/djangoapps/student/tests/test_refunds.py index aa100ef2adea1aa8cdd8b86bfc16564fb279b3b1..0c3e4ff16f728731e2dffe42e4ab0a0407bb32c3 100644 --- a/common/djangoapps/student/tests/test_refunds.py +++ b/common/djangoapps/student/tests/test_refunds.py @@ -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): """ diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index 0eead5ab3847ab4ec64feeb8b40909ca14c996f9..c6d31c588a8900c171d882abe1ed3c9e06372217 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -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, diff --git a/lms/envs/common.py b/lms/envs/common.py index a4b51690c89277ba45669e72220e212df4617b07..281a5cf189fe7e995888f0b46452eb83e332861d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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' diff --git a/lms/envs/production.py b/lms/envs/production.py index 2723ea998043ac86382e67acce0d031f6b11d148..4639bbeaafdf79c88810bbb73691fb606094410c 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -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 \ diff --git a/lms/static/js/dashboard/legacy.js b/lms/static/js/dashboard/legacy.js index 29f7ccb777200d88d9a589b4b32f760593accdfe..966dc4c93d7ed5a71c2fa1cf06bf4ea4434f5e78 100644 --- a/lms/static/js/dashboard/legacy.js +++ b/lms/static/js/dashboard/legacy.js @@ -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(); diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 5369df1551a5cddb727b045cc9aa94dbea7c03c8..b0351a41cbdc4eeaa07185561d100d8418e5bd60 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -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/> diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 1a0e7338224a1266b7211b6dd2889290d30cc560..95a67035f81370072e8e7617bde5e1f69d89f174 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -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 @@ -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')} diff --git a/openedx/features/enterprise_support/signals.py b/openedx/features/enterprise_support/signals.py index 84d321c98519b117e43810732c2bfda06dcebb6f..454141c100fac601a607199d9d11588abdb301ae 100644 --- a/openedx/features/enterprise_support/signals.py +++ b/openedx/features/enterprise_support/signals.py @@ -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) diff --git a/openedx/features/enterprise_support/tests/test_signals.py b/openedx/features/enterprise_support/tests/test_signals.py index 9143e0e3556225ddd1acecef13c842db989a95e5..b101ebf0b5b91f75790494f40339f52b6abda0b9 100644 --- a/openedx/features/enterprise_support/tests/test_signals.py +++ b/openedx/features/enterprise_support/tests/test_signals.py @@ -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 diff --git a/themes/edx.org/lms/templates/dashboard.html b/themes/edx.org/lms/templates/dashboard.html index a91f29d443ca07ca35fdf936a8ed34e18c043e11..2406ae90b777f95b5703b0ff9dd641082bb89083 100644 --- a/themes/edx.org/lms/templates/dashboard.html +++ b/themes/edx.org/lms/templates/dashboard.html @@ -241,6 +241,7 @@ 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)) @@ -248,7 +249,7 @@ from common.djangoapps.student.models import CourseEnrollment course_overview = enrollment.course_overview 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/>