fix: Remove B2C Subscriptions (#35303)

REV-3697
This commit is contained in:
Juliana Kang
2024-09-04 14:01:45 -04:00
committed by GitHub
parent ef4e03729e
commit 51d538cbe7
50 changed files with 9 additions and 1709 deletions

View File

@@ -4,7 +4,6 @@ requiring Superuser access for all other Request types on an API endpoint.
"""
from django.conf import settings
from rest_framework.permissions import SAFE_METHODS, BasePermission
from lms.djangoapps.courseware.access import has_access
@@ -22,12 +21,3 @@ class IsAdminOrSupportOrAuthenticatedReadOnly(BasePermission):
return request.user.is_authenticated
else:
return request.user.is_staff or has_access(request.user, "support", "global")
class IsSubscriptionWorkerUser(BasePermission):
"""
Method that will require the request to be coming from the subscriptions service worker user.
"""
def has_permission(self, request, view):
return request.user.username == settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME

View File

@@ -6,7 +6,6 @@ import logging
import uuid
from datetime import datetime, timedelta
from unittest.mock import patch
from uuid import uuid4
from django.conf import settings
from django.urls import reverse
@@ -1236,160 +1235,3 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
assert CourseEnrollment.is_enrolled(self.user, self.course.id)
assert course_entitlement.enrollment_course_run is not None
assert course_entitlement.expired_at is None
@skip_unless_lms
class RevokeSubscriptionsVerifiedAccessViewTest(ModuleStoreTestCase):
"""
Tests for the RevokeVerifiedAccessView
"""
REVOKE_VERIFIED_ACCESS_PATH = 'entitlements_api:v1:revoke_subscriptions_verified_access'
def setUp(self):
super().setUp()
self.user = UserFactory(username="subscriptions_worker", is_staff=True)
self.client.login(username=self.user.username, password=TEST_PASSWORD)
self.course = CourseFactory()
self.course_mode1 = CourseModeFactory(
course_id=self.course.id, # pylint: disable=no-member
mode_slug=CourseMode.VERIFIED,
expiration_datetime=now() + timedelta(days=1)
)
self.course_mode2 = CourseModeFactory(
course_id=self.course.id, # pylint: disable=no-member
mode_slug=CourseMode.AUDIT,
expiration_datetime=now() + timedelta(days=1)
)
@patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status')
def test_revoke_access_success(self, mock_get_courses_completion_status):
mock_get_courses_completion_status.return_value = ([], False)
enrollment = CourseEnrollmentFactory.create(
user=self.user,
course_id=self.course.id, # pylint: disable=no-member
is_active=True,
mode=CourseMode.VERIFIED
)
course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment)
url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH)
assert course_entitlement.enrollment_course_run is not None
response = self.client.post(
url,
data={
"entitlement_uuids": [str(course_entitlement.uuid)],
"lms_user_id": self.user.id
},
content_type='application/json',
)
assert response.status_code == 204
course_entitlement.refresh_from_db()
enrollment.refresh_from_db()
assert course_entitlement.expired_at is not None
assert course_entitlement.enrollment_course_run is None
assert enrollment.mode == CourseMode.AUDIT
@patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status')
def test_already_completed_course(self, mock_get_courses_completion_status):
enrollment = CourseEnrollmentFactory.create(
user=self.user,
course_id=self.course.id, # pylint: disable=no-member
is_active=True,
mode=CourseMode.VERIFIED
)
mock_get_courses_completion_status.return_value = ([str(enrollment.course_id)], False)
course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment)
url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH)
assert course_entitlement.enrollment_course_run is not None
response = self.client.post(
url,
data={
"entitlement_uuids": [str(course_entitlement.uuid)],
"lms_user_id": self.user.id
},
content_type='application/json',
)
assert response.status_code == 204
course_entitlement.refresh_from_db()
assert course_entitlement.expired_at is None
assert course_entitlement.enrollment_course_run.mode == CourseMode.VERIFIED
@patch('common.djangoapps.entitlements.rest_api.v1.views.log.info')
def test_revoke_access_invalid_uuid(self, mock_log):
url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH)
entitlement_uuids = [str(uuid4())]
response = self.client.post(
url,
data={
"entitlement_uuids": entitlement_uuids,
"lms_user_id": self.user.id
},
content_type='application/json',
)
mock_log.assert_called_once_with("B2C_SUBSCRIPTIONS: Entitlements not found for the provided"
" entitlements data: %s and user: %s",
entitlement_uuids,
self.user.id)
assert response.status_code == 204
def test_revoke_access_unauthorized_user(self):
user = UserFactory(is_staff=True, username='not_subscriptions_worker')
self.client.login(username=user.username, password=TEST_PASSWORD)
enrollment = CourseEnrollmentFactory.create(
user=self.user,
course_id=self.course.id, # pylint: disable=no-member
is_active=True,
mode=CourseMode.VERIFIED
)
course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment)
url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH)
assert course_entitlement.enrollment_course_run is not None
response = self.client.post(
url,
data={
"entitlement_uuids": [],
"lms_user_id": self.user.id
},
content_type='application/json',
)
assert response.status_code == 403
course_entitlement.refresh_from_db()
assert course_entitlement.expired_at is None
assert course_entitlement.enrollment_course_run.mode == CourseMode.VERIFIED
@patch('common.djangoapps.entitlements.tasks.retry_revoke_subscriptions_verified_access.apply_async')
@patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status')
def test_course_completion_exception_triggers_task(self, mock_get_courses_completion_status, mock_task):
mock_get_courses_completion_status.return_value = ([], True)
enrollment = CourseEnrollmentFactory.create(
user=self.user,
course_id=self.course.id, # pylint: disable=no-member
is_active=True,
mode=CourseMode.VERIFIED
)
course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment)
url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH)
response = self.client.post(
url,
data={
"entitlement_uuids": [str(course_entitlement.uuid)],
"lms_user_id": self.user.id
},
content_type='application/json',
)
assert response.status_code == 204
mock_task.assert_called_once_with(args=([str(course_entitlement.uuid)],
[str(enrollment.course_id)],
self.user.username))

View File

@@ -1,21 +0,0 @@
"""
Throttle classes for the entitlements API.
"""
from django.conf import settings
from rest_framework.throttling import UserRateThrottle
class ServiceUserThrottle(UserRateThrottle):
"""A throttle allowing service users to override rate limiting"""
def allow_request(self, request, view):
"""Returns True if the request is coming from one of the service users
and defaults to UserRateThrottle's configured setting otherwise.
"""
service_users = [
settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME
]
if request.user.username in service_users:
return True
return super().allow_request(request, view)

View File

@@ -6,7 +6,7 @@ from django.urls import include
from django.urls import path, re_path
from rest_framework.routers import DefaultRouter
from .views import EntitlementEnrollmentViewSet, EntitlementViewSet, SubscriptionsRevokeVerifiedAccessView
from .views import EntitlementEnrollmentViewSet, EntitlementViewSet
router = DefaultRouter()
router.register(r'entitlements', EntitlementViewSet, basename='entitlements')
@@ -24,9 +24,4 @@ urlpatterns = [
ENROLLMENTS_VIEW,
name='enrollments'
),
path(
'subscriptions/entitlements/revoke',
SubscriptionsRevokeVerifiedAccessView.as_view(),
name='revoke_subscriptions_verified_access'
)
]

View File

@@ -15,7 +15,6 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import permissions, status, viewsets
from rest_framework.response import Response
from rest_framework.views import APIView
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.entitlements.models import ( # lint-amnesty, pylint: disable=line-too-long
@@ -24,22 +23,13 @@ from common.djangoapps.entitlements.models import ( # lint-amnesty, pylint: dis
CourseEntitlementSupportDetail
)
from common.djangoapps.entitlements.rest_api.v1.filters import CourseEntitlementFilter
from common.djangoapps.entitlements.rest_api.v1.permissions import (
IsAdminOrSupportOrAuthenticatedReadOnly,
IsSubscriptionWorkerUser
)
from common.djangoapps.entitlements.rest_api.v1.permissions import IsAdminOrSupportOrAuthenticatedReadOnly
from common.djangoapps.entitlements.rest_api.v1.serializers import CourseEntitlementSerializer
from common.djangoapps.entitlements.rest_api.v1.throttles import ServiceUserThrottle
from common.djangoapps.entitlements.tasks import retry_revoke_subscriptions_verified_access
from common.djangoapps.entitlements.utils import (
is_course_run_entitlement_fulfillable,
revoke_entitlements_and_downgrade_courses_to_audit
)
from common.djangoapps.entitlements.utils import is_course_run_entitlement_fulfillable
from common.djangoapps.student.models import AlreadyEnrolledError, CourseEnrollment, CourseEnrollmentException
from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course, get_owners_for_course
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from openedx.core.djangoapps.credentials.utils import get_courses_completion_status
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
User = get_user_model()
@@ -132,7 +122,6 @@ class EntitlementViewSet(viewsets.ModelViewSet):
filter_backends = (DjangoFilterBackend,)
filterset_class = CourseEntitlementFilter
pagination_class = EntitlementsPagination
throttle_classes = (ServiceUserThrottle,)
def get_queryset(self):
user = self.request.user
@@ -530,68 +519,3 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
})
return Response(status=status.HTTP_204_NO_CONTENT)
class SubscriptionsRevokeVerifiedAccessView(APIView):
"""
Endpoint for expiring entitlements for a user and downgrading the enrollments
to Audit mode. This endpoint accepts a list of entitlement UUIDs and will expire
the entitlements along with downgrading the related enrollments to Audit mode.
Only those enrollments are downgraded to Audit for which user has not been awarded
a completion certificate yet.
"""
authentication_classes = (JwtAuthentication, SessionAuthenticationCrossDomainCsrf,)
permission_classes = (permissions.IsAuthenticated, IsSubscriptionWorkerUser,)
throttle_classes = (ServiceUserThrottle,)
def _process_revoke_and_downgrade_to_audit(self, course_entitlements, user_id, revocable_entitlement_uuids):
"""
Gets course completion status for the provided course entitlements and triggers the
revoke and downgrade to audit process for the course entitlements which are not completed.
Triggers the retry task asynchronously if there is an exception while getting the
course completion status.
"""
entitled_course_ids = []
user = User.objects.get(id=user_id)
username = user.username
for course_entitlement in course_entitlements:
if course_entitlement.enrollment_course_run is not None:
entitled_course_ids.append(str(course_entitlement.enrollment_course_run.course_id))
log.info('B2C_SUBSCRIPTIONS: Getting course completion status for user [%s] and entitled_course_ids %s',
username,
entitled_course_ids)
awarded_cert_course_ids, is_exception = get_courses_completion_status(username, entitled_course_ids)
if is_exception:
# Trigger the retry task asynchronously
log.exception('B2C_SUBSCRIPTIONS: Exception occurred while getting course completion status for user %s '
'and entitled_course_ids %s',
username,
entitled_course_ids)
retry_revoke_subscriptions_verified_access.apply_async(args=(revocable_entitlement_uuids,
entitled_course_ids,
username))
return
revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids,
revocable_entitlement_uuids)
def post(self, request):
"""
Invokes the entitlements expiration process for the provided uuids and downgrades the
enrollments to Audit mode.
"""
revocable_entitlement_uuids = request.data.get('entitlement_uuids', [])
user_id = request.data.get('lms_user_id', None)
course_entitlements = (CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids).
select_related('user').
select_related('enrollment_course_run'))
if course_entitlements.exists():
self._process_revoke_and_downgrade_to_audit(course_entitlements, user_id, revocable_entitlement_uuids)
return Response(status=status.HTTP_204_NO_CONTENT)
else:
log.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements data: %s and user: %s',
revocable_entitlement_uuids,
user_id)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -4,15 +4,12 @@ This file contains celery tasks for entitlements-related functionality.
import logging
from celery import shared_task
from celery.exceptions import MaxRetriesExceededError
from celery.utils.log import get_task_logger
from django.conf import settings # lint-amnesty, pylint: disable=unused-import
from django.contrib.auth import get_user_model
from edx_django_utils.monitoring import set_code_owner_attribute
from common.djangoapps.entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail
from common.djangoapps.entitlements.utils import revoke_entitlements_and_downgrade_courses_to_audit
from openedx.core.djangoapps.credentials.utils import get_courses_completion_status
LOGGER = get_task_logger(__name__)
log = logging.getLogger(__name__)
@@ -154,40 +151,3 @@ def expire_and_create_entitlements(self, entitlement_ids, support_username):
'%d entries, task id :%s',
len(entitlement_ids),
self.request.id)
@shared_task(bind=True)
@set_code_owner_attribute
def retry_revoke_subscriptions_verified_access(self, revocable_entitlement_uuids, entitled_course_ids, username):
"""
Task to process course access revoke and move to audit.
This is called only if call to get_courses_completion_status fails due to any exception.
"""
LOGGER.info("B2C_SUBSCRIPTIONS: Running retry_revoke_subscriptions_verified_access for user [%s],"
" entitlement_uuids %s and entitled_course_ids %s",
username,
revocable_entitlement_uuids,
entitled_course_ids)
course_entitlements = CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids)
course_entitlements = course_entitlements.select_related('user').select_related('enrollment_course_run')
if course_entitlements.exists():
awarded_cert_course_ids, is_exception = get_courses_completion_status(username, entitled_course_ids)
if is_exception:
try:
countdown = 2 ** self.request.retries
self.retry(countdown=countdown, max_retries=3)
except MaxRetriesExceededError:
LOGGER.exception(
'B2C_SUBSCRIPTIONS: Failed to process retry_revoke_subscriptions_verified_access '
'for user [%s] and entitlement_uuids %s',
username,
revocable_entitlement_uuids
)
return
revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids,
revocable_entitlement_uuids)
else:
LOGGER.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements uuids %s '
'for user [%s] duing the retry_revoke_subscriptions_verified_access task',
revocable_entitlement_uuids,
username)

View File

@@ -5277,19 +5277,6 @@ paths:
required: true
type: string
format: uuid
/entitlements/v1/subscriptions/entitlements/revoke:
post:
operationId: entitlements_v1_subscriptions_entitlements_revoke_create
description: |-
Invokes the entitlements expiration process for the provided uuids and downgrades the
enrollments to Audit mode.
parameters: []
responses:
'201':
description: ''
tags:
- entitlements
parameters: []
/experiments/v0/custom/REV-934/:
get:
operationId: experiments_v0_custom_REV-934_list

View File

@@ -37,20 +37,3 @@ ENABLE_MASTERS_PROGRAM_TAB_VIEW = WaffleFlag(
'learner_dashboard.enable_masters_program_tab_view',
__name__,
)
# .. toggle_name: learner_dashboard.enable_b2c_subscriptions
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable new B2C Subscriptions Program data.
# This flag is used to decide whether we need to enable program subscription related properties in program listing
# and detail pages.
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2023-04-13
# .. toggle_target_removal_date: 2023-07-01
# .. toggle_warning: When the flag is ON, the new B2C Subscriptions Program data will be enabled in program listing
# and detail pages.
# .. toggle_tickets: PON-79
ENABLE_B2C_SUBSCRIPTIONS = WaffleFlag(
'learner_dashboard.enable_b2c_subscriptions',
__name__,
)

View File

@@ -6,7 +6,6 @@ import json
from abc import ABC, abstractmethod
from urllib.parse import quote
from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
from django.http import Http404
from django.template.loader import render_to_string
@@ -18,7 +17,7 @@ from web_fragments.fragment import Fragment
from common.djangoapps.student.models import anonymous_id_for_user
from common.djangoapps.student.roles import GlobalStaff
from lms.djangoapps.learner_dashboard.utils import b2c_subscriptions_enabled, program_tab_view_is_enabled
from lms.djangoapps.learner_dashboard.utils import program_tab_view_is_enabled
from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.core.djangoapps.programs.models import (
@@ -32,9 +31,7 @@ from openedx.core.djangoapps.programs.utils import (
get_industry_and_credit_pathways,
get_program_and_course_data,
get_program_marketing_url,
get_program_subscriptions_marketing_url,
get_program_urls,
get_programs_subscription_data
)
from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences
from openedx.core.djangolib.markup import HTML
@@ -60,30 +57,12 @@ class ProgramsFragmentView(EdxFragmentView):
raise Http404
meter = ProgramProgressMeter(request.site, user, mobile_only=mobile_only)
is_user_b2c_subscriptions_enabled = b2c_subscriptions_enabled(mobile_only)
programs_subscription_data = (
get_programs_subscription_data(user)
if is_user_b2c_subscriptions_enabled
else []
)
subscription_upsell_data = (
{
'marketing_url': get_program_subscriptions_marketing_url(),
'minimum_price': settings.SUBSCRIPTIONS_MINIMUM_PRICE,
'trial_length': settings.SUBSCRIPTIONS_TRIAL_LENGTH,
}
if is_user_b2c_subscriptions_enabled
else {}
)
context = {
'marketing_url': get_program_marketing_url(programs_config, mobile_only),
'programs': meter.engaged_programs,
'progress': meter.progress(),
'programs_subscription_data': programs_subscription_data,
'subscription_upsell_data': subscription_upsell_data,
'user_preferences': get_user_preferences(user),
'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled,
'mobile_only': bool(mobile_only)
}
html = render_to_string('learner_dashboard/programs_fragment.html', context)
@@ -137,12 +116,6 @@ class ProgramDetailsFragmentView(EdxFragmentView):
program_discussion_lti = ProgramDiscussionLTI(program_uuid, request)
program_live_lti = ProgramLiveLTI(program_uuid, request)
is_user_b2c_subscriptions_enabled = b2c_subscriptions_enabled(mobile_only)
program_subscription_data = (
get_programs_subscription_data(user, program_uuid)
if is_user_b2c_subscriptions_enabled
else []
)
def program_tab_view_enabled() -> bool:
return program_tab_view_is_enabled() and (
@@ -156,14 +129,11 @@ class ProgramDetailsFragmentView(EdxFragmentView):
'urls': urls,
'user_preferences': get_user_preferences(user),
'program_data': program_data,
'program_subscription_data': program_subscription_data,
'course_data': course_data,
'certificate_data': certificate_data,
'industry_pathways': industry_pathways,
'credit_pathways': credit_pathways,
'program_tab_view_enabled': program_tab_view_enabled(),
'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled,
'subscriptions_trial_length': settings.SUBSCRIPTIONS_TRIAL_LENGTH,
'discussion_fragment': {
'configured': program_discussion_lti.is_configured,
'iframe': program_discussion_lti.render_iframe()

View File

@@ -7,7 +7,6 @@ from opaque_keys.edx.keys import CourseKey
from common.djangoapps.student.roles import GlobalStaff
from lms.djangoapps.learner_dashboard.config.waffle import (
ENABLE_B2C_SUBSCRIPTIONS,
ENABLE_MASTERS_PROGRAM_TAB_VIEW,
ENABLE_PROGRAM_TAB_VIEW
)
@@ -50,19 +49,3 @@ def is_enrolled_or_staff(request, program_uuid):
except ObjectDoesNotExist:
return False
return True
def b2c_subscriptions_is_enabled() -> bool:
"""
Check if B2C program subscriptions flag is enabled.
"""
return ENABLE_B2C_SUBSCRIPTIONS.is_enabled()
def b2c_subscriptions_enabled(is_mobile=False) -> bool:
"""
Check whether B2C Subscriptions pages should be shown to user.
"""
if not is_mobile and b2c_subscriptions_is_enabled():
return True
return False

View File

@@ -4691,7 +4691,6 @@ ENTERPRISE_ALL_SERVICE_USERNAMES = [
'enterprise_channel_worker',
'enterprise_access_worker',
'enterprise_subsidy_worker',
'subscriptions_worker'
]
# Setting for Open API key and prompts used by edx-enterprise.
@@ -5385,17 +5384,6 @@ ENTERPRISE_MANUAL_REPORTING_CUSTOMER_UUIDS = []
AVAILABLE_DISCUSSION_TOURS = []
######################## Subscriptions API SETTINGS ########################
SUBSCRIPTIONS_ROOT_URL = ""
SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/"
SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None
SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/"
SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None
SUBSCRIPTIONS_MINIMUM_PRICE = '$39'
SUBSCRIPTIONS_TRIAL_LENGTH = 7
SUBSCRIPTIONS_SERVICE_WORKER_USERNAME = 'subscriptions_worker'
############## NOTIFICATIONS ##############
NOTIFICATIONS_EXPIRY = 60
EXPIRED_NOTIFICATIONS_DELETE_BATCH_SIZE = 10000

View File

@@ -522,16 +522,6 @@ course_access_role_removed_event_setting = EVENT_BUS_PRODUCER_CONFIG[
]
course_access_role_removed_event_setting['learning-course-access-role-lifecycle']['enabled'] = True
######################## Subscriptions API SETTINGS ########################
SUBSCRIPTIONS_ROOT_URL = "http://host.docker.internal:18750"
SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/"
SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None
SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/"
SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None
SUBSCRIPTIONS_MINIMUM_PRICE = '$39'
SUBSCRIPTIONS_TRIAL_LENGTH = 7
# API access management
API_ACCESS_MANAGER_EMAIL = 'api-access@example.com'
API_ACCESS_FROM_EMAIL = 'api-requests@example.com'

View File

@@ -650,15 +650,6 @@ SURVEY_REPORT_CHECK_THRESHOLD = 6
SURVEY_REPORT_ENABLE = True
ANONYMOUS_SURVEY_REPORT = False
######################## Subscriptions API SETTINGS ########################
SUBSCRIPTIONS_ROOT_URL = "http://localhost:18750"
SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/"
SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None
SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/"
SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None
SUBSCRIPTIONS_MINIMUM_PRICE = '$39'
SUBSCRIPTIONS_TRIAL_LENGTH = 7
CSRF_TRUSTED_ORIGINS = ['.example.com']
CSRF_TRUSTED_ORIGINS_WITH_SCHEME = ['https://*.example.com']

View File

@@ -1,86 +0,0 @@
import Backbone from 'backbone';
import moment from 'moment';
import DateUtils from 'edx-ui-toolkit/js/utils/date-utils';
import StringUtils from 'edx-ui-toolkit/js/utils/string-utils';
/**
* Model for Program Subscription Data.
*/
class ProgramSubscriptionModel extends Backbone.Model {
constructor({ context }, ...args) {
const {
subscriptionData: [data = {}],
programData: { subscription_prices },
urls = {},
userPreferences = {},
subscriptionsTrialLength: trialLength = 7,
} = context;
const priceInUSD = subscription_prices?.find(({ currency }) => currency === 'USD');
const subscriptionState = data.subscription_state?.toLowerCase() ?? '';
const subscriptionPrice = StringUtils.interpolate(
gettext('${price}/month {currency}'),
{
price: parseFloat(priceInUSD?.price),
currency: priceInUSD?.currency,
}
);
const subscriptionUrl =
subscriptionState === 'active'
? urls.manage_subscription_url
: urls.buy_subscription_url;
const hasActiveTrial = false;
const remainingDays = 0;
const [currentPeriodEnd] = ProgramSubscriptionModel.formatDate(
data.current_period_end,
userPreferences
);
const [trialEndDate, trialEndTime] = ['', ''];
super(
{
hasActiveTrial,
currentPeriodEnd,
remainingDays,
subscriptionPrice,
subscriptionState,
subscriptionUrl,
trialEndDate,
trialEndTime,
trialLength,
},
...args
);
}
static formatDate(date, userPreferences) {
if (!date) {
return ['', ''];
}
const userTimezone = (
userPreferences.time_zone || moment?.tz?.guess?.() || 'UTC'
);
const userLanguage = userPreferences['pref-lang'] || 'en';
const context = {
datetime: date,
timezone: userTimezone,
language: userLanguage,
format: DateUtils.dateFormatEnum.shortDate,
};
const localDate = DateUtils.localize(context);
const localTime = '';
return [localDate, localTime];
}
}
export default ProgramSubscriptionModel;

View File

@@ -11,58 +11,18 @@ import HeaderView from './views/program_list_header_view';
function ProgramListFactory(options) {
const progressCollection = new ProgressCollection();
const subscriptionCollection = new Backbone.Collection();
if (options.userProgress) {
progressCollection.set(options.userProgress);
options.progressCollection = progressCollection; // eslint-disable-line no-param-reassign
}
if (options.programsSubscriptionData.length) {
subscriptionCollection.set(options.programsSubscriptionData);
options.subscriptionCollection = subscriptionCollection; // eslint-disable-line no-param-reassign
}
if (options.programsData.length) {
if (!options.mobileOnly) {
new HeaderView({
context: options,
}).render();
}
const activeSubscriptions = options.programsSubscriptionData
// eslint-disable-next-line camelcase
.filter(({ subscription_state }) => subscription_state === 'active')
.sort((a, b) => new Date(b.created) - new Date(a.created));
// Sort programs so programs with active subscriptions are at the top
if (activeSubscriptions.length) {
// eslint-disable-next-line no-param-reassign
options.programsData = options.programsData
.map((programsData) => ({
...programsData,
subscriptionIndex: activeSubscriptions.findIndex(
// eslint-disable-next-line camelcase
({ resource_id }) => resource_id === programsData.uuid,
),
}))
.sort(({ subscriptionIndex: indexA }, { subscriptionIndex: indexB }) => {
switch (true) {
case indexA === -1 && indexB === -1:
// Maintain the original order for non-subscription programs
return 0;
case indexA === -1:
// Move non-subscription program to the end
return 1;
case indexB === -1:
// Keep non-subscription program to the end
return -1;
default:
// Sort by subscriptionIndex in ascending order
return indexA - indexB;
}
});
}
}
new CollectionListView({

View File

@@ -1,7 +1,5 @@
/* globals setFixtures */
import Backbone from 'backbone';
import CollectionListView from '../views/collection_list_view';
import ProgramCardView from '../views/program_card_view';
import ProgramCollection from '../collections/program_collection';
@@ -11,7 +9,6 @@ describe('Collection List View', () => {
let view = null;
let programCollection;
let progressCollection;
let subscriptionCollection;
const context = {
programsData: [
{
@@ -101,21 +98,14 @@ describe('Collection List View', () => {
not_started: 3,
},
],
programsSubscriptionData: [{
resource_id: 'a87e5eac-3c93-45a1-a8e1-4c79ca8401c8',
subscription_state: 'active',
}],
isUserB2CSubscriptionsEnabled: false,
};
beforeEach(() => {
setFixtures('<div class="program-cards-container"></div>');
programCollection = new ProgramCollection(context.programsData);
progressCollection = new ProgressCollection();
subscriptionCollection = new Backbone.Collection(context.programsSubscriptionData);
progressCollection.set(context.userProgress);
context.progressCollection = progressCollection;
context.subscriptionCollection = subscriptionCollection;
view = new CollectionListView({
el: '.program-cards-container',

View File

@@ -17,10 +17,8 @@ describe('Course Card View', () => {
programData,
collectionCourseStatus,
courseData: {},
subscriptionData: [],
urls: {},
userPreferences: {},
isSubscriptionEligible: false,
};
if (typeof collectionCourseStatus === 'undefined') {

View File

@@ -1,58 +0,0 @@
/* globals setFixtures */
import ProgramAlertListView from '../views/program_alert_list_view';
describe('Program Alert List View', () => {
let view = null;
const context = {
enrollmentAlerts: [{ title: 'Test Program' }],
trialEndingAlerts: [{
title: 'Test Program',
hasActiveTrial: true,
currentPeriodEnd: 'May 8, 2023',
remainingDays: 2,
subscriptionPrice: '$100/month USD',
subscriptionState: 'active',
subscriptionUrl: null,
trialEndDate: 'Apr 20, 2023',
trialEndTime: '5:59 am',
trialLength: 7,
}],
pageType: 'programDetails',
};
beforeEach(() => {
setFixtures('<div class="js-program-details-alerts"></div>');
view = new ProgramAlertListView({
el: '.js-program-details-alerts',
context,
});
view.render();
});
afterEach(() => {
view.remove();
});
it('should exist', () => {
expect(view).toBeDefined();
});
it('should render no enrollement alert', () => {
expect(view.$('.alert:first .alert-heading').text().trim()).toEqual(
'Enroll in a Test Program\'s course',
);
expect(view.$('.alert:first .alert-message').text().trim()).toEqual(
'You have an active subscription to the Test Program program but are not enrolled in any courses. Enroll in a remaining course and enjoy verified access.',
);
});
it('should render subscription trial is expiring alert', () => {
expect(view.$('.alert:last .alert-heading').text().trim()).toEqual(
'Subscription trial expires in 2 days',
);
expect(view.$('.alert:last .alert-message').text().trim()).toEqual(
'Your Test Program trial will expire in 2 days at 5:59 am on Apr 20, 2023 and the card on file will be charged $100/month USD.',
);
});
});

View File

@@ -42,7 +42,6 @@ describe('Program card View', () => {
name: 'Wageningen University & Research',
},
],
subscriptionIndex: 1,
};
const userProgress = [
{
@@ -58,11 +57,6 @@ describe('Program card View', () => {
not_started: 3,
},
];
// eslint-disable-next-line no-undef
const subscriptionCollection = new Backbone.Collection([{
resource_id: 'a87e5eac-3c93-45a1-a8e1-4c79ca8401c8',
subscription_state: 'active',
}]);
const progressCollection = new ProgressCollection();
const cardRenders = ($card) => {
expect($card).toBeDefined();
@@ -80,8 +74,6 @@ describe('Program card View', () => {
model: programModel,
context: {
progressCollection,
subscriptionCollection,
isUserB2CSubscriptionsEnabled: true,
},
});
});
@@ -133,10 +125,6 @@ describe('Program card View', () => {
view.remove();
view = new ProgramCardView({
model: programModel,
context: {
subscriptionCollection,
isUserB2CSubscriptionsEnabled: true,
},
});
cardRenders(view.$el);
expect(view.$('.progress').length).toEqual(0);
@@ -149,10 +137,6 @@ describe('Program card View', () => {
programModel = new ProgramModel(programNoBanner);
view = new ProgramCardView({
model: programModel,
context: {
subscriptionCollection,
isUserB2CSubscriptionsEnabled: true,
},
});
cardRenders(view.$el);
expect(view.$el.find('.banner-image').attr('srcset')).toEqual('');
@@ -167,16 +151,8 @@ describe('Program card View', () => {
programModel = new ProgramModel(programNoBanner);
view = new ProgramCardView({
model: programModel,
context: {
subscriptionCollection,
isUserB2CSubscriptionsEnabled: true,
},
});
cardRenders(view.$el);
expect(view.$el.find('.banner-image').attr('srcset')).toEqual('');
});
it('should render the subscription badge if subscription is active', () => {
expect(view.$('.subscription-badge .badge').html()?.trim()).toEqual('Subscribed');
});
});

View File

@@ -45,16 +45,6 @@ describe('Program Details Header View', () => {
},
],
},
subscriptionData: [
{
trial_end: '1970-01-01T03:25:45Z',
current_period_end: '1970-06-03T07:12:04Z',
price: '100.00',
currency: 'USD',
subscription_state: 'active',
},
],
isSubscriptionEligible: true,
};
beforeEach(() => {
@@ -81,8 +71,4 @@ describe('Program Details Header View', () => {
expect(view.$('.org-logo').attr('alt'))
.toEqual(`${context.programData.authoring_organizations[0].name}'s logo`);
});
it('should render the subscription badge if subscription is active', () => {
expect(view.$('.meta-info .badge').html().trim()).toEqual('Subscribed');
});
});

View File

@@ -1,9 +1,7 @@
/* globals setFixtures */
import Backbone from 'backbone';
import moment from 'moment';
import SubscriptionModel from '../models/program_subscription_model';
import ProgramSidebarView from '../views/program_details_sidebar_view';
describe('Program Progress View', () => {
@@ -25,15 +23,13 @@ describe('Program Progress View', () => {
"url": "/certificates/bed3980e67ca40f0b31e309d9dfe9e7e", "type": "course", "title": "Introduction to the Treatment of Urban Sewage"
}
],
urls: {"program_listing_url": "/dashboard/programs/", "commerce_api_url": "/api/commerce/v0/baskets/", "track_selection_url": "/course_modes/choose/", "program_record_url": "/foo/bar", "buy_subscription_url": "/subscriptions", "orders_and_subscriptions_url": "/orders", "subscriptions_learner_help_center_url": "/learner"},
urls: {"program_listing_url": "/dashboard/programs/", "commerce_api_url": "/api/commerce/v0/baskets/", "track_selection_url": "/course_modes/choose/"},
userPreferences: {"pref-lang": "en"}
};
/* eslint-enable */
let programModel;
let courseData;
let subscriptionData;
let certificateCollection;
let isSubscriptionEligible;
const testCircle = (progress) => {
const $circle = view.$('.progress-circle');
@@ -53,55 +49,15 @@ describe('Program Progress View', () => {
expect(parseInt($numbers.find('.total').html(), 10)).toEqual(total);
};
const testSubscriptionState = (state, heading, body) => {
isSubscriptionEligible = true;
subscriptionData.subscription_state = state;
// eslint-disable-next-line no-use-before-define
view = initView();
// eslint-disable-next-line no-param-reassign
body += ' on the <a class="subscription-link" href="/orders">Orders and subscriptions</a> page';
expect(view.$('.js-subscription-info')[0]).toBeInDOM();
expect(
view.$('.js-subscription-info .divider-heading').text().trim(),
).toEqual(heading);
expect(
view.$('.js-subscription-info .subscription-section p:nth-child(1)'),
).toContainHtml(body);
expect(
view.$('.js-subscription-info .subscription-section p:nth-child(2)'),
).toContainText(
/Need help\? Check out the.*Learner Help Center.*to troubleshoot issues or contact support/,
);
expect(
view.$('.js-subscription-info .subscription-section p:nth-child(2) .subscription-link').attr('href'),
).toEqual('/learner');
};
const initView = () => new ProgramSidebarView({
el: '.js-program-sidebar',
model: programModel,
courseModel: courseData,
subscriptionModel: new SubscriptionModel({
context: {
programData: {
subscription_eligible: isSubscriptionEligible,
subscription_prices: [{
price: '100.00',
currency: 'USD',
}],
},
subscriptionData: [subscriptionData],
urls: data.urls,
userPreferences: data.userPreferences,
},
}),
certificateCollection,
industryPathways: data.industryPathways,
creditPathways: data.creditPathways,
programTabViewEnabled: false,
urls: data.urls,
isSubscriptionEligible,
});
beforeEach(() => {
@@ -109,14 +65,6 @@ describe('Program Progress View', () => {
programModel = new Backbone.Model(data.programData);
courseData = new Backbone.Model(data.courseData);
certificateCollection = new Backbone.Collection(data.certificateData);
isSubscriptionEligible = false;
subscriptionData = {
trial_end: '1970-01-01T03:25:45Z',
current_period_end: '1970-06-03T07:12:04Z',
price: '100.00',
currency: 'USD',
subscription_state: 'pre',
};
});
afterEach(() => {
@@ -203,69 +151,14 @@ describe('Program Progress View', () => {
el: '.js-program-sidebar',
model: programModel,
courseModel: courseData,
subscriptionModel: new SubscriptionModel({
context: {
programData: {
subscription_eligible: isSubscriptionEligible,
subscription_prices: [{
price: '100.00',
currency: 'USD',
}],
},
subscriptionData: [subscriptionData],
urls: data.urls,
userPreferences: data.userPreferences,
},
}),
certificateCollection,
industryPathways: [],
creditPathways: [],
programTabViewEnabled: false,
urls: data.urls,
isSubscriptionEligible,
});
expect(emptyView.$('.program-credit-pathways .divider-heading')).toHaveLength(0);
expect(emptyView.$('.program-industry-pathways .divider-heading')).toHaveLength(0);
});
it('should not render subscription info if program is not subscription eligible', () => {
view = initView();
expect(view.$('.js-subscription-info')[0]).not.toBeInDOM();
});
it('should render subscription info if program is subscription eligible', () => {
testSubscriptionState(
'pre',
'Inactive subscription',
'If you had a subscription previously, your payment history is still available',
);
});
it('should render active trial subscription info if subscription is active with trial', () => {
subscriptionData.trial_end = moment().add(3, 'days').utc().format(
'YYYY-MM-DDTHH:mm:ss[Z]',
);
testSubscriptionState(
'active',
'Trial subscription',
'View your receipts or modify your subscription',
);
});
it('should render active subscription info if subscription active', () => {
testSubscriptionState(
'active',
'Active subscription',
'View your receipts or modify your subscription',
);
});
it('should render inactive subscription info if subscription inactive', () => {
testSubscriptionState(
'inactive',
'Inactive subscription',
'Restart your subscription for $100/month USD. Your payment history is still available',
);
});
});

View File

@@ -7,11 +7,6 @@ describe('Program Details View', () => {
let view = null;
const options = {
programData: {
subscription_eligible: false,
subscription_prices: [{
price: '100.00',
currency: 'USD',
}],
subtitle: '',
overview: '',
weeks_to_complete: null,
@@ -468,24 +463,11 @@ describe('Program Details View', () => {
},
],
},
subscriptionData: [
{
trial_end: '1970-01-01T03:25:45Z',
current_period_end: '1970-06-03T07:12:04Z',
price: '100.00',
currency: 'USD',
subscription_state: 'pre',
},
],
urls: {
program_listing_url: '/dashboard/programs/',
commerce_api_url: '/api/commerce/v0/baskets/',
track_selection_url: '/course_modes/choose/',
program_record_url: 'http://credentials.example.com/records/programs/UUID',
buy_subscription_url: '/subscriptions',
manage_subscription_url: '/orders',
subscriptions_learner_help_center_url: '/learner',
orders_and_subscriptions_url: '/orders',
},
userPreferences: {
'pref-lang': 'en',
@@ -513,59 +495,9 @@ describe('Program Details View', () => {
},
],
programTabViewEnabled: false,
isUserB2CSubscriptionsEnabled: false,
};
const data = options.programData;
const testSubscriptionState = (state, heading, body, trial = false) => {
const subscriptionData = {
...options.subscriptionData[0],
subscription_state: state,
};
if (trial) {
subscriptionData.trial_end = moment().add(3, 'days').utc().format(
'YYYY-MM-DDTHH:mm:ss[Z]',
);
}
// eslint-disable-next-line no-use-before-define
view = initView({
// eslint-disable-next-line no-undef
programData: $.extend({}, options.programData, {
subscription_eligible: true,
}),
isUserB2CSubscriptionsEnabled: true,
subscriptionData: [subscriptionData],
});
view.render();
expect(view.$('.upgrade-subscription')[0]).toBeInDOM();
expect(view.$('.upgrade-subscription .upgrade-button'))
.toContainText(heading);
expect(view.$('.upgrade-subscription .subscription-info-brief'))
.toContainText(body);
};
const testSubscriptionSunsetting = (state, heading, body) => {
const subscriptionData = {
...options.subscriptionData[0],
subscription_state: state,
};
// eslint-disable-next-line no-use-before-define
view = initView({
// eslint-disable-next-line no-undef
programData: $.extend({}, options.programData, {
subscription_eligible: false,
}),
isUserB2CSubscriptionsEnabled: true,
subscriptionData: [subscriptionData],
});
view.render();
expect(view.$('.upgrade-subscription')[0]).not.toBeInDOM();
expect(view.$('.upgrade-subscription .upgrade-button')).not
.toContainText(heading);
expect(view.$('.upgrade-subscription .subscription-info-brief')).not
.toContainText(body);
};
const initView = (updates) => {
// eslint-disable-next-line no-undef
const viewOptions = $.extend({}, options, updates);
@@ -730,37 +662,4 @@ describe('Program Details View', () => {
properties,
);
});
it('should not render the get subscription link if program is not active', () => {
testSubscriptionSunsetting(
'pre',
'Start 7-day free trial',
'$100/month USD subscription after trial ends. Cancel anytime.',
);
});
it('should not render appropriate subscription text when subscription is active with trial', () => {
testSubscriptionSunsetting(
'active',
'Manage my subscription',
'Trial ends',
true,
);
});
it('should not render appropriate subscription text when subscription is active', () => {
testSubscriptionSunsetting(
'active',
'Manage my subscription',
'Your next billing date is',
);
});
it('should not render appropriate subscription text when subscription is inactive', () => {
testSubscriptionSunsetting(
'inactive',
'Restart my subscription',
'$100/month USD subscription. Cancel anytime.',
);
});
});

View File

@@ -13,27 +13,14 @@ describe('Program List Header View', () => {
{
uuid: '5b234e3c-3a2e-472e-90db-6f51501dc86c',
title: 'edX Demonstration Program',
subscription_eligible: null,
subscription_prices: [],
detail_url: '/dashboard/programs/5b234e3c-3a2e-472e-90db-6f51501dc86c/',
},
{
uuid: 'b90d70d5-f981-4508-bdeb-5b792d930c03',
title: 'Test Program',
subscription_eligible: true,
subscription_prices: [{ price: '500.00', currency: 'USD' }],
detail_url: '/dashboard/programs/b90d70d5-f981-4508-bdeb-5b792d930c03/',
},
],
programsSubscriptionData: [
{
id: 'eeb25640-9741-4c11-963c-8a27337f217c',
resource_id: 'b90d70d5-f981-4508-bdeb-5b792d930c03',
trial_end: '2022-04-20T05:59:42Z',
current_period_end: '2023-05-08T05:59:42Z',
subscription_state: 'active',
},
],
userProgress: [
{
uuid: '5b234e3c-3a2e-472e-90db-6f51501dc86c',
@@ -50,13 +37,9 @@ describe('Program List Header View', () => {
all_unenrolled: true,
},
],
isUserB2CSubscriptionsEnabled: true,
};
beforeEach(() => {
context.subscriptionCollection = new Backbone.Collection(
context.programsSubscriptionData,
);
context.progressCollection = new ProgressCollection(
context.userProgress,
);
@@ -78,18 +61,4 @@ describe('Program List Header View', () => {
it('should render the program heading', () => {
expect(view.$('h2:first').text().trim()).toEqual('My programs');
});
it('should render a program alert', () => {
expect(
view.$('.js-program-list-alerts .alert .alert-heading').html().trim(),
).toEqual('Enroll in a Test Program\'s course');
expect(
view.$('.js-program-list-alerts .alert .alert-message'),
).toContainHtml(
'According to our records, you are not enrolled in any courses included in your Test Program program subscription. Enroll in a course from the <i>Program Details</i> page.',
);
expect(
view.$('.js-program-list-alerts .alert .view-button').attr('href'),
).toEqual('/dashboard/programs/b90d70d5-f981-4508-bdeb-5b792d930c03/');
});
});

View File

@@ -6,12 +6,6 @@ describe('Sidebar View', () => {
let view = null;
const context = {
marketingUrl: 'https://www.example.org/programs',
subscriptionUpsellData: {
marketing_url: 'https://www.example.org/program-subscriptions',
minimum_price: '$39',
trial_length: 7,
},
isUserB2CSubscriptionsEnabled: true,
};
beforeEach(() => {
@@ -32,10 +26,6 @@ describe('Sidebar View', () => {
expect(view).toBeDefined();
});
it('should not render the subscription upsell section', () => {
expect(view.$('.js-subscription-upsell')[0]).not.toBeInDOM();
});
it('should load the exploration panel given a marketing URL', () => {
expect(view.$('.program-advertise .advertise-message').html().trim())
.toEqual(
@@ -49,10 +39,6 @@ describe('Sidebar View', () => {
view.remove();
view = new SidebarView({
el: '.sidebar',
context: {
isUserB2CSubscriptionsEnabled: true,
subscriptionUpsellData: context.subscriptionUpsellData,
},
});
view.render();
const $ad = view.$el.find('.program-advertise');

View File

@@ -9,8 +9,6 @@ import ExpiredNotificationView from './expired_notification_view';
import CourseEnrollView from './course_enroll_view';
import EntitlementView from './course_entitlement_view';
import SubscriptionModel from '../models/program_subscription_model';
import pageTpl from '../../../templates/learner_dashboard/course_card.underscore';
class CourseCardView extends Backbone.View {
@@ -27,9 +25,6 @@ class CourseCardView extends Backbone.View {
this.enrollModel = new EnrollModel();
if (options.context) {
this.urlModel = new Backbone.Model(options.context.urls);
this.subscriptionModel = new SubscriptionModel({
context: options.context,
});
this.enrollModel.urlRoot = this.urlModel.get('commerce_api_url');
}
this.context = options.context || {};
@@ -93,8 +88,6 @@ class CourseCardView extends Backbone.View {
this.upgradeMessage = new UpgradeMessageView({
$el: $upgradeMessage,
model: this.model,
subscriptionModel: this.subscriptionModel,
isSubscriptionEligible: this.context.isSubscriptionEligible,
});
$certStatus.remove();

View File

@@ -1,89 +0,0 @@
import Backbone from 'backbone';
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
import StringUtils from 'edx-ui-toolkit/js/utils/string-utils';
import warningIcon from '../../../images/warning-icon.svg';
import programAlertTpl from '../../../templates/learner_dashboard/program_alert_list_view.underscore';
class ProgramAlertListView extends Backbone.View {
constructor(options) {
const defaults = {
el: '.js-program-details-alerts',
};
// eslint-disable-next-line prefer-object-spread
super(Object.assign({}, defaults, options));
}
initialize({ context }) {
this.tpl = HtmlUtils.template(programAlertTpl);
this.enrollmentAlerts = context.enrollmentAlerts || [];
this.trialEndingAlerts = context.trialEndingAlerts || [];
this.pageType = context.pageType;
this.render();
}
render() {
const data = {
alertList: this.getAlertList(),
warningIcon,
};
HtmlUtils.setHtml(this.$el, this.tpl(data));
}
getAlertList() {
const alertList = this.enrollmentAlerts.map(
({ title: programName, url }) => ({
url,
// eslint-disable-next-line no-undef
urlText: gettext('View program'),
title: StringUtils.interpolate(
// eslint-disable-next-line no-undef
gettext('Enroll in a {programName}\'s course'),
{ programName },
),
message: this.pageType === 'programDetails'
? StringUtils.interpolate(
// eslint-disable-next-line no-undef
gettext('You have an active subscription to the {programName} program but are not enrolled in any courses. Enroll in a remaining course and enjoy verified access.'),
{ programName },
)
: HtmlUtils.interpolateHtml(
// eslint-disable-next-line no-undef
gettext('According to our records, you are not enrolled in any courses included in your {programName} program subscription. Enroll in a course from the {i_start}Program Details{i_end} page.'),
{
programName,
i_start: HtmlUtils.HTML('<i>'),
i_end: HtmlUtils.HTML('</i>'),
},
),
}),
);
return alertList.concat(this.trialEndingAlerts.map(
({ title: programName, remainingDays, ...data }) => ({
title: StringUtils.interpolate(
remainingDays < 1
// eslint-disable-next-line no-undef
? gettext('Subscription trial expires in less than 24 hours')
// eslint-disable-next-line no-undef
: ngettext('Subscription trial expires in {remainingDays} day', 'Subscription trial expires in {remainingDays} days', remainingDays),
{ remainingDays },
),
message: StringUtils.interpolate(
remainingDays < 1
// eslint-disable-next-line no-undef
? gettext('Your {programName} trial will expire at {trialEndTime} on {trialEndDate} and the card on file will be charged {subscriptionPrice}.')
// eslint-disable-next-line no-undef
: ngettext('Your {programName} trial will expire in {remainingDays} day at {trialEndTime} on {trialEndDate} and the card on file will be charged {subscriptionPrice}.', 'Your {programName} trial will expire in {remainingDays} days at {trialEndTime} on {trialEndDate} and the card on file will be charged {subscriptionPrice}.', remainingDays),
{
programName,
remainingDays,
...data,
},
),
}),
));
}
}
export default ProgramAlertListView;

View File

@@ -30,10 +30,6 @@ class ProgramCardView extends Backbone.View {
uuid: this.model.get('uuid'),
});
}
this.isSubscribed = (
context.isUserB2CSubscriptionsEnabled &&
this.model.get('subscriptionIndex') > -1
) ?? false;
this.render();
}
@@ -45,7 +41,6 @@ class ProgramCardView extends Backbone.View {
this.getProgramProgress(),
{
orgList: orgList.join(' '),
isSubscribed: this.isSubscribed,
},
);

View File

@@ -30,9 +30,7 @@ class ProgramDetailsSidebarView extends Backbone.View {
this.industryPathways = options.industryPathways;
this.creditPathways = options.creditPathways;
this.programModel = options.model;
this.subscriptionModel = options.subscriptionModel;
this.programTabViewEnabled = options.programTabViewEnabled;
this.isSubscriptionEligible = options.isSubscriptionEligible;
this.urls = options.urls;
this.render();
}
@@ -42,14 +40,12 @@ class ProgramDetailsSidebarView extends Backbone.View {
const data = $.extend(
{},
this.model.toJSON(),
this.subscriptionModel.toJSON(),
{
programCertificate: this.programCertificate
? this.programCertificate.toJSON() : {},
industryPathways: this.industryPathways,
creditPathways: this.creditPathways,
programTabViewEnabled: this.programTabViewEnabled,
isSubscriptionEligible: this.isSubscriptionEligible,
arrowUprightIcon,
...this.urls,
},

View File

@@ -10,10 +10,6 @@ import CourseCardView from './course_card_view';
// eslint-disable-next-line import/no-named-as-default, import/no-named-as-default-member
import HeaderView from './program_header_view';
import SidebarView from './program_details_sidebar_view';
import AlertListView from './program_alert_list_view';
// eslint-disable-next-line import/no-named-as-default, import/no-named-as-default-member
import SubscriptionModel from '../models/program_subscription_model';
import launchIcon from '../../../images/launch-icon.svg';
import restartIcon from '../../../images/restart-icon.svg';
@@ -27,7 +23,6 @@ class ProgramDetailsView extends Backbone.View {
el: '.js-program-details-wrapper',
events: {
'click .complete-program': 'trackPurchase',
'click .js-subscription-cta': 'trackSubscriptionCTA',
},
};
// eslint-disable-next-line prefer-object-spread
@@ -46,9 +41,6 @@ class ProgramDetailsView extends Backbone.View {
this.certificateCollection = new Backbone.Collection(
this.options.certificateData,
);
this.subscriptionModel = new SubscriptionModel({
context: this.options,
});
this.completedCourseCollection = new CourseCardCollection(
this.courseData.get('completed') || [],
this.options.userPreferences,
@@ -61,11 +53,6 @@ class ProgramDetailsView extends Backbone.View {
this.courseData.get('not_started') || [],
this.options.userPreferences,
);
this.subscriptionEventParams = {
label: this.options.programData.title,
program_uuid: this.options.programData.uuid,
};
this.options.isSubscriptionEligible = this.getIsSubscriptionEligible();
this.render();
@@ -76,7 +63,6 @@ class ProgramDetailsView extends Backbone.View {
pageName: 'program_dashboard',
linkCategory: 'green_upgrade',
});
this.trackSubscriptionEligibleProgramView();
}
static getUrl(base, programData) {
@@ -107,7 +93,6 @@ class ProgramDetailsView extends Backbone.View {
creditPathways: this.options.creditPathways,
discussionFragment: this.options.discussionFragment,
live_fragment: this.options.live_fragment,
isSubscriptionEligible: this.options.isSubscriptionEligible,
launchIcon,
restartIcon,
};
@@ -115,7 +100,6 @@ class ProgramDetailsView extends Backbone.View {
data = $.extend(
data,
this.programModel.toJSON(),
this.subscriptionModel.toJSON(),
);
HtmlUtils.setHtml(this.$el, this.tpl(data));
this.postRender();
@@ -126,20 +110,6 @@ class ProgramDetailsView extends Backbone.View {
model: new Backbone.Model(this.options),
});
if (this.options.isSubscriptionEligible) {
const { enrollmentAlerts, trialEndingAlerts } = this.getAlerts();
if (enrollmentAlerts.length || trialEndingAlerts.length) {
this.alertListView = new AlertListView({
context: {
enrollmentAlerts,
trialEndingAlerts,
pageType: 'programDetails',
},
});
}
}
if (this.remainingCourseCollection.length > 0) {
new CollectionListView({
el: '.js-course-list-remaining',
@@ -178,12 +148,10 @@ class ProgramDetailsView extends Backbone.View {
el: '.js-program-sidebar',
model: this.programModel,
courseModel: this.courseData,
subscriptionModel: this.subscriptionModel,
certificateCollection: this.certificateCollection,
industryPathways: this.options.industryPathways,
creditPathways: this.options.creditPathways,
programTabViewEnabled: this.options.programTabViewEnabled,
isSubscriptionEligible: this.options.isSubscriptionEligible,
urls: this.options.urls,
});
let hasIframe = false;
@@ -197,59 +165,6 @@ class ProgramDetailsView extends Backbone.View {
}).bind(this);
}
getIsSubscriptionEligible() {
const courseCollections = [
this.completedCourseCollection,
this.inProgressCourseCollection,
];
const isSomeCoursePurchasable = courseCollections.some((collection) => (
collection.some((course) => (
course.get('upgrade_url')
&& !(course.get('expired') === true)
))
));
const programPurchasedWithoutSubscription = (
this.subscriptionModel.get('subscriptionState') !== 'active'
&& this.subscriptionModel.get('subscriptionState') !== 'inactive'
&& !isSomeCoursePurchasable
&& this.remainingCourseCollection.length === 0
);
const isSubscriptionActiveSunsetting = (
this.subscriptionModel.get('subscriptionState') === 'active'
)
return (
this.options.isUserB2CSubscriptionsEnabled
&& isSubscriptionActiveSunsetting
&& !programPurchasedWithoutSubscription
);
}
getAlerts() {
const alerts = {
enrollmentAlerts: [],
trialEndingAlerts: [],
};
if (this.subscriptionModel.get('subscriptionState') === 'active') {
if (this.courseData.get('all_unenrolled')) {
alerts.enrollmentAlerts.push({
title: this.programModel.get('title'),
});
}
if (
this.subscriptionModel.get('remainingDays') <= 7
&& this.subscriptionModel.get('hasActiveTrial')
) {
alerts.trialEndingAlerts.push({
title: this.programModel.get('title'),
...this.subscriptionModel.toJSON(),
});
}
}
return alerts;
}
trackPurchase() {
const data = this.options.programData;
window.analytics.track('edx.bi.user.dashboard.program.purchase', {
@@ -258,37 +173,6 @@ class ProgramDetailsView extends Backbone.View {
uuid: data.uuid,
});
}
trackSubscriptionCTA() {
const state = this.subscriptionModel.get('subscriptionState');
if (state === 'active') {
window.analytics.track(
'edx.bi.user.subscription.program-detail-page.manage.clicked',
this.subscriptionEventParams,
);
} else {
const isNewSubscription = state !== 'inactive';
window.analytics.track(
'edx.bi.user.subscription.program-detail-page.subscribe.clicked',
{
category: `${this.options.programData.variant} bundle`,
is_new_subscription: isNewSubscription,
is_trial_eligible: isNewSubscription,
...this.subscriptionEventParams,
},
);
}
}
trackSubscriptionEligibleProgramView() {
if (this.options.isSubscriptionEligible) {
window.analytics.track(
'edx.bi.user.subscription.program-detail-page.viewed',
this.subscriptionEventParams,
);
}
}
}
export default ProgramDetailsView;

View File

@@ -42,22 +42,11 @@ class ProgramHeaderView extends Backbone.View {
return logo;
}
getIsSubscribed() {
const isSubscriptionEligible = this.model.get('isSubscriptionEligible');
const subscriptionData = this.model.get('subscriptionData')?.[0];
return (
isSubscriptionEligible &&
subscriptionData?.subscription_state === 'active'
);
}
render() {
// eslint-disable-next-line no-undef
const data = $.extend(this.model.toJSON(), {
breakpoints: this.breakpoints,
logo: this.getLogo(),
isSubscribed: this.getIsSubscribed(),
});
if (this.model.get('programData')) {

View File

@@ -2,10 +2,6 @@ import Backbone from 'backbone';
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
import AlertListView from './program_alert_list_view';
import SubscriptionModel from '../models/program_subscription_model';
import programListHeaderTpl from '../../../templates/learner_dashboard/program_list_header_view.underscore';
class ProgramListHeaderView extends Backbone.View {
@@ -19,76 +15,11 @@ class ProgramListHeaderView extends Backbone.View {
initialize({ context }) {
this.context = context;
this.tpl = HtmlUtils.template(programListHeaderTpl);
this.programAndSubscriptionData = context.programsData
.map((programData) => ({
programData,
subscriptionData: context.subscriptionCollection
?.findWhere({
resource_id: programData.uuid,
subscription_state: 'active',
})
?.toJSON(),
}))
.filter(({ subscriptionData }) => !!subscriptionData);
this.render();
}
render() {
HtmlUtils.setHtml(this.$el, this.tpl(this.context));
this.postRender();
}
postRender() {
if (this.context.isUserB2CSubscriptionsEnabled) {
const enrollmentAlerts = this.getEnrollmentAlerts();
const trialEndingAlerts = this.getTrialEndingAlerts();
if (enrollmentAlerts.length || trialEndingAlerts.length) {
this.alertListView = new AlertListView({
el: '.js-program-list-alerts',
context: {
enrollmentAlerts,
trialEndingAlerts,
pageType: 'programList',
},
});
}
}
}
getEnrollmentAlerts() {
return this.programAndSubscriptionData
.map(({ programData, subscriptionData }) =>
this.context.progressCollection?.findWhere({
uuid: programData.uuid,
all_unenrolled: true,
}) ? {
title: programData.title,
url: programData.detail_url,
} : null
)
.filter(Boolean);
}
getTrialEndingAlerts() {
return this.programAndSubscriptionData
.map(({ programData, subscriptionData }) => {
const subscriptionModel = new SubscriptionModel({
context: {
programData,
subscriptionData: [subscriptionData],
userPreferences: this.context?.userPreferences,
},
});
return (
subscriptionModel.get('remainingDays') <= 7 &&
subscriptionModel.get('hasActiveTrial') && {
title: programData.title,
...subscriptionModel.toJSON(),
}
);
})
.filter(Boolean);
}
}

View File

@@ -10,9 +10,6 @@ class SidebarView extends Backbone.View {
constructor(options) {
const defaults = {
el: '.sidebar',
events: {
'click .js-subscription-upsell-cta ': 'trackSubscriptionUpsellCTA',
},
};
// eslint-disable-next-line prefer-object-spread
super(Object.assign({}, defaults, options));
@@ -33,12 +30,6 @@ class SidebarView extends Backbone.View {
context: this.context,
});
}
trackSubscriptionUpsellCTA() {
window.analytics.track(
'edx.bi.user.subscription.program-dashboard.upsell.clicked',
);
}
}
export default SidebarView;

View File

@@ -1,30 +0,0 @@
import Backbone from 'backbone';
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
import subscriptionUpsellTpl from '../../../templates/learner_dashboard/subscription_upsell_view.underscore';
class SubscriptionUpsellView extends Backbone.View {
constructor(options) {
const defaults = {
el: '.js-subscription-upsell',
};
// eslint-disable-next-line prefer-object-spread
super(Object.assign({}, defaults, options));
}
initialize(options) {
this.tpl = HtmlUtils.template(subscriptionUpsellTpl);
this.subscriptionUpsellModel = new Backbone.Model(
options.subscriptionUpsellData,
);
this.render();
}
render() {
const data = this.subscriptionUpsellModel.toJSON();
HtmlUtils.setHtml(this.$el, this.tpl(data));
}
}
export default SubscriptionUpsellView;

View File

@@ -3,18 +3,12 @@ import Backbone from 'backbone';
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
import upgradeMessageTpl from '../../../templates/learner_dashboard/upgrade_message.underscore';
import upgradeMessageSubscriptionTpl from '../../../templates/learner_dashboard/upgrade_message_subscription.underscore';
import trackECommerceEvents from '../../commerce/track_ecommerce_events';
class UpgradeMessageView extends Backbone.View {
initialize(options) {
if (options.isSubscriptionEligible) {
this.messageTpl = HtmlUtils.template(upgradeMessageSubscriptionTpl);
} else {
this.messageTpl = HtmlUtils.template(upgradeMessageTpl);
}
this.messageTpl = HtmlUtils.template(upgradeMessageTpl);
this.$el = options.$el;
this.subscriptionModel = options.subscriptionModel;
this.render();
const courseUpsellButtons = this.$el.find('.program_dashboard_course_upsell_button');
@@ -30,7 +24,6 @@ class UpgradeMessageView extends Backbone.View {
const data = $.extend(
{},
this.model.toJSON(),
this.subscriptionModel.toJSON(),
);
HtmlUtils.setHtml(this.$el, this.messageTpl(data));
}

View File

@@ -90,21 +90,6 @@ $btn-color-primary: $primary-dark;
}
}
.program-details-alerts {
.page-banner {
margin: 0;
padding: 0 0 48px;
gap: 24px;
}
}
.program-details-tab-alerts {
.page-banner {
margin: 0;
gap: 24px;
}
}
// CSS for April 2017 version of Program Details Page
.program-details {
.window-wrap {
@@ -449,42 +434,6 @@ $btn-color-primary: $primary-dark;
}
}
.upgrade-subscription {
margin: 16px 0 10px;
row-gap: 16px;
column-gap: 24px;
}
.subscription-icon-launch {
width: 22.5px;
height: 22.5px;
margin-inline-start: 8px;
}
.subscription-icon-restart {
width: 22.5px;
height: 22.5px;
margin-inline-end: 8px;
}
.subscription-icon-arrow-upright {
display: inline-flex;
align-items: center;
width: 15px;
height: 15px;
margin-inline-start: 8px;
}
.subscription-info-brief {
font-size: 0.9375em;
color: $gray-500;
}
.subscription-info-upsell {
margin-top: 0.25rem;
font-size: 0.8125em;
}
.program-course-card {
width: 100%;
padding: 15px 15px 15px 0px;
@@ -681,24 +630,6 @@ $btn-color-primary: $primary-dark;
.program-sidebar {
padding: 40px 40px 40px 0px;
.program-record,.subscription-info {
text-align: left;
padding-bottom: 2em;
}
.subscription-section {
display: flex;
flex-direction: column;
gap: 16px;
color: #414141;
.subscription-link {
color: inherit;
text-decoration: none;
border-bottom: 1px solid currentColor;
}
}
.sidebar-section {
font-size: 0.9375em;
width: auto;

View File

@@ -39,13 +39,6 @@
.program-cards-container {
@include grid-container();
padding-top: 32px;
.subscription-badge {
position: absolute;
top: 8px;
left: 8px;
z-index: 10;
}
}
.sidebar {

View File

@@ -61,8 +61,3 @@
</picture>
</div>
</a>
<% if (isSubscribed) { %>
<div class="subscription-badge">
<span class="badge badge-light"><%- gettext('Subscribed') %></span>
</div>
<% } %>

View File

@@ -14,7 +14,6 @@ from openedx.core.djangolib.js_utils import (
<%static:webpack entry="ProgramDetailsFactory">
ProgramDetailsFactory({
programData: ${program_data | n, dump_js_escaped_json},
subscriptionData: ${program_subscription_data | n, dump_js_escaped_json},
courseData: ${course_data | n, dump_js_escaped_json},
certificateData: ${certificate_data | n, dump_js_escaped_json},
urls: ${urls | n, dump_js_escaped_json},
@@ -22,8 +21,6 @@ ProgramDetailsFactory({
industryPathways: ${industry_pathways | n, dump_js_escaped_json},
creditPathways: ${credit_pathways | n, dump_js_escaped_json},
programTabViewEnabled: ${program_tab_view_enabled | n, dump_js_escaped_json},
isUserB2CSubscriptionsEnabled: ${is_user_b2c_subscriptions_enabled | n, dump_js_escaped_json},
subscriptionsTrialLength: ${subscriptions_trial_length | n, dump_js_escaped_json},
discussionFragment: ${discussion_fragment, | n, dump_js_escaped_json},
live_fragment: ${live_fragment, | n, dump_js_escaped_json}
});

View File

@@ -8,50 +8,6 @@
<% } %>
</aside>
<aside class="aside js-course-certificates"></aside>
<% if (isSubscriptionEligible) { %>
<aside class="aside js-subscription-info subscription-info">
<h2 class="divider-heading">
<%- hasActiveTrial
? gettext('Trial subscription')
: subscriptionState === 'active'
? gettext('Active subscription')
: gettext('Inactive subscription')
%>
</h2>
<div class="sidebar-section">
<div class="subscription-section">
<p class="my-0">
<%= HtmlUtils.interpolateHtml(
(
subscriptionState === 'active'
? gettext('View your receipts or modify your subscription on the {a_start}Orders and subscriptions{a_end} page')
: subscriptionState === 'inactive'
? gettext('Restart your subscription for {subscriptionPrice}. Your payment history is still available on the {a_start}Orders and subscriptions{a_end} page')
: gettext('If you had a subscription previously, your payment history is still available on the {a_start}Orders and subscriptions{a_end} page')
),
{
subscriptionPrice,
a_start: HtmlUtils.HTML(`<a class="subscription-link" href="${orders_and_subscriptions_url}">`),
a_end: HtmlUtils.HTML('</a>'),
}
) %>
</p>
<p class="my-0">
<%= HtmlUtils.interpolateHtml(
gettext('Need help? Check out the {a_start}Learner Help Center{span_start}{icon}{span_end}{a_end} to troubleshoot issues or contact support'),
{
a_start: HtmlUtils.HTML(`<a class="subscription-link" href="${subscriptions_learner_help_center_url}" target="_blank" rel="noopener noreferrer">`),
a_end: HtmlUtils.HTML('</a>'),
span_start: HtmlUtils.HTML('<span class="subscription-icon-arrow-upright">'),
icon: HtmlUtils.HTML(arrowUprightIcon),
span_end: HtmlUtils.HTML('</span>'),
}
) %>
</p>
</div>
</div>
</aside>
<% } %>
<aside class="aside js-program-record program-record">
<h2 class="divider-heading"><%- gettext('Program Record') %></h2>
<div class="sidebar-section">

View File

@@ -1,5 +1,4 @@
<header class="js-program-header program-header full-width-banner"></header>
<div class="js-program-details-alerts program-details-tab-alerts program-subscription-alert-wrapper col-12 col-md-8"></div>
<!-- TODO: consider if article is the most appropriate element here -->
<% if (programTabViewEnabled) { %>
@@ -46,9 +45,7 @@
</div>
</div>
<% } %>
<% if (
!isSubscriptionEligible
&& is_learner_eligible_for_one_click_purchase
<% if (is_learner_eligible_for_one_click_purchase
&& (typeof is_mobile_only === 'undefined' || is_mobile_only === false)
) { %>
<a href="<%- completeProgramURL %>" class="btn-brand btn cta-primary upgrade-button complete-program" id="program_dashboard_course_upsell_all_button">

View File

@@ -1,5 +1,4 @@
<header class="js-program-header program-header full-width-banner"></header>
<div class="js-program-details-alerts program-details-alerts program-subscription-alert-wrapper col-12 col-md-8"></div>
<!-- TODO: consider if article is the most appropriate element here -->
<div class="col-12 flex-column flex-md-row d-md-flex">
@@ -22,8 +21,7 @@
</div>
<% } %>
<% if (
!isSubscriptionEligible
&& is_learner_eligible_for_one_click_purchase
is_learner_eligible_for_one_click_purchase
&& (typeof is_mobile_only === 'undefined' || is_mobile_only === false)
) { %>
<a href="<%- completeProgramURL %>" class="btn-brand btn cta-primary upgrade-button complete-program" id="program_dashboard_course_upsell_all_button">

View File

@@ -1,10 +1,5 @@
<div class="program-details-header">
<div class="meta-info grid-container">
<% if (isSubscribed) { %>
<div class="mb-3">
<span class="badge badge-light"><%- gettext('Subscribed') %></span>
</div>
<% } %>
<% if (logo) { %>
<% // xss-lint: disable=underscore-not-escaped %>
<span aria-label="<%- gettext(programData.type) %>" class="<%- programData.type.toLowerCase() %> program-details-icon"><%= logo %></span>

View File

@@ -1,2 +1 @@
<h2><%- gettext('My programs') %></h2>
<div class="js-program-list-alerts program-list-alerts program-subscription-alert-wrapper mr-md-3"></div>

View File

@@ -31,11 +31,8 @@ from openedx.core.djangolib.js_utils import (
ProgramListFactory({
marketingUrl: '${marketing_url | n, js_escaped_string}',
programsData: ${programs | n, dump_js_escaped_json},
programsSubscriptionData: ${programs_subscription_data | n, dump_js_escaped_json},
subscriptionUpsellData: ${subscription_upsell_data | n, dump_js_escaped_json},
userProgress: ${progress | n, dump_js_escaped_json},
userPreferences: ${user_preferences | n, dump_js_escaped_json},
isUserB2CSubscriptionsEnabled: ${is_user_b2c_subscriptions_enabled | n, dump_js_escaped_json},
mobileOnly: ${mobile_only | n, dump_js_escaped_json}
});
</%static:webpack>

View File

@@ -1,20 +0,0 @@
<span class="badge badge-warning align-self-start"><%- gettext('New') %></span>
<h4 class="m-0">
<%= HtmlUtils.interpolateHtml(
gettext('Monthly program subscriptions {emDash} more flexible, more affordable'),
{ emDash: HtmlUtils.HTML('&mdash;') }
) %>
</h4>
<p class="advertise-message">
<%- StringUtils.interpolate(
gettext('Now available for many popular programs, affordable monthly subscription pricing can help you manage your budget more effectively. Subscriptions start at {minSubscriptionPrice}/month USD per program, after a 7-day full access free trial. Cancel at any time.'),
{
minSubscriptionPrice: minimum_price,
trialLength: trial_length,
}
) %>
</p>
<a href="<%- marketing_url %>" class="js-subscription-upsell-cta btn-brand btn cta-primary view-button align-self-stretch">
<span class="icon fa fa-search" aria-hidden="true"></span>
<span><%- gettext('Explore subscription options') %></span>
</a>

View File

@@ -1,21 +0,0 @@
<div class="message certificate-status col-12 md-col-8">
<span class="card-msg"><%- gettext('Certificate Status:') %></span>
<span><%- gettext('Needs verified certificate ') %></span>
</div>
<% if ( subscriptionState !== 'active' ) { %>
<div class="action d-flex flex-column align-items-start align-items-md-end">
<a href="<%- subscriptionUrl %>" class="btn-brand btn cta-primary upgrade-button single-course-run program_dashboard_course_upsell_button">
<%- gettext('Upgrade with a subscription') %>
</a>
<span class="subscription-info-upsell">
<%- StringUtils.interpolate(
(
subscriptionState === 'inactive'
? gettext('Pay {subscriptionPrice} for all courses in this program')
: gettext('Pay {subscriptionPrice} after {trialLength}-day free trial')
),
{ subscriptionPrice, trialLength },
) %>
</span>
</div>
<% } %>

View File

@@ -3,16 +3,11 @@
import uuid
from unittest import mock
from django.conf import settings
from requests import Response
from requests.exceptions import HTTPError
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
from openedx.core.djangoapps.credentials.tests import factories
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
from openedx.core.djangoapps.credentials.utils import (
get_courses_completion_status,
get_credentials,
get_credentials_records_url,
)
@@ -107,33 +102,3 @@ class TestGetCredentials(CredentialsApiConfigMixin, CacheIsolationTestCase):
result = get_credentials_records_url("abcdefgh-ijkl-mnop-qrst-uvwxyz123456")
assert result == "https://credentials.example.com/records/programs/abcdefghijklmnopqrstuvwxyz123456"
@mock.patch("requests.Response.raise_for_status")
@mock.patch("requests.Response.json")
@mock.patch(UTILS_MODULE + ".get_credentials_api_client")
def test_get_courses_completion_status(self, mock_get_api_client, mock_json, mock_raise):
"""
Test to verify the functionality of get_courses_completion_status
"""
UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME)
course_statuses = factories.UserCredentialsCourseRunStatus.create_batch(3)
response_data = [course_status["course_run"]["key"] for course_status in course_statuses]
mock_raise.return_value = None
mock_json.return_value = {
"lms_user_id": self.user.id,
"status": course_statuses,
"username": self.user.username,
}
mock_get_api_client.return_value.post.return_value = Response()
course_run_keys = [course_status["course_run"]["key"] for course_status in course_statuses]
api_response, is_exception = get_courses_completion_status(self.user.id, course_run_keys)
assert api_response == response_data
assert is_exception is False
@mock.patch("requests.Response.raise_for_status")
def test_get_courses_completion_status_api_error(self, mock_raise):
mock_raise.return_value = HTTPError("An Error occured")
UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME)
api_response, is_exception = get_courses_completion_status(self.user.id, ["fake1", "fake2", "fake3"])
assert api_response == []
assert is_exception is True

View File

@@ -5,7 +5,6 @@ from typing import Dict, List
from urllib.parse import urljoin
import requests
from django.conf import settings
from django.contrib.auth import get_user_model
from edx_rest_api_client.auth import SuppliedJwtAuth
@@ -121,59 +120,3 @@ def get_credentials(
cache_key=cache_key,
raise_on_error=raise_on_error,
)
def get_courses_completion_status(username, course_run_ids):
"""
Given the username and course run ids, checks for course completion status
Arguments:
username (User): Username of the user whose credentials are being requested.
course_run_ids(List): list of course run ids for which we need to check the completion status
Returns:
list of course_run_ids for which user has completed the course
Boolean: True if an exception occurred while calling the api, False otherwise
"""
credential_configuration = CredentialsApiConfig.current()
if not credential_configuration.enabled:
log.warning("%s configuration is disabled.", credential_configuration.API_NAME)
return [], False
completion_status_url = f"{settings.CREDENTIALS_INTERNAL_SERVICE_URL}/api" "/credentials/v1/learner_cert_status/"
try:
api_client = get_credentials_api_client(User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME))
api_response = api_client.post(
completion_status_url,
json={
"username": username,
"course_runs": course_run_ids,
},
)
api_response.raise_for_status()
course_completion_response = api_response.json()
except Exception as exc: # pylint: disable=broad-except
log.exception(
"An unexpected error occurred while reqeusting course completion statuses "
"for user [%s] for course_run_ids [%s] with exc [%s]:",
username,
course_run_ids,
exc,
)
return [], True
log.info(
"Course completion status response for user [%s] for course_run_ids [%s] is [%s]",
username,
course_run_ids,
course_completion_response,
)
# Yes, This is course_credentials_data. The key is named status but
# it contains all the courses data from credentials.
course_credentials_data = course_completion_response.get("status", [])
if course_credentials_data is not None:
filtered_records = [
course_data["course_run"]["key"]
for course_data in course_credentials_data
if course_data["course_run"]["key"] in course_run_ids
and course_data["status"] == settings.CREDENTIALS_COURSE_COMPLETION_STATE
]
return filtered_records, False
return [], False

View File

@@ -6,7 +6,6 @@ import uuid
from collections import namedtuple
from copy import deepcopy
from unittest import mock
from urllib.parse import urlencode
import ddt
import httpretty
@@ -44,10 +43,8 @@ from openedx.core.djangoapps.programs.utils import (
ProgramDataExtender,
ProgramMarketingDataExtender,
ProgramProgressMeter,
get_buy_subscription_url,
get_certificates,
get_logged_in_program_certificate_url,
get_programs_subscription_data,
is_user_enrolled_in_program_type
)
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
@@ -1759,100 +1756,3 @@ class TestProgramEnrollment(SharedModuleStoreTestCase):
)
mock_get_programs_by_type.return_value = [self.program]
assert is_user_enrolled_in_program_type(user=self.user, program_type_slug=self.MICROBACHELORS)
@skip_unless_lms
class TestGetProgramsSubscriptionData(TestCase):
"""
Tests for the get_programs_subscription_data utility function.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.mock_program_subscription_data = [
{'id': uuid.uuid4(), 'resource_id': uuid.uuid4(),
'resource_type': 'program', 'resource_data': None, 'trial_end': '1970-01-01T00:02:03Z',
'price': '100.00', 'currency': 'USD', 'sub_type': 'stripe', 'identifier': 'dummy_1',
'current_period_end': '1970-01-01T00:02:03Z', 'status': 'active',
'customer': 1, 'subscription_state': 'active'},
{'id': uuid.uuid4(), 'resource_id': uuid.uuid4(),
'resource_type': 'program', 'resource_data': None, 'trial_end': '1970-01-01T03:25:12Z',
'price': '1000.00', 'currency': 'USD', 'sub_type': 'stripe', 'identifier': 'dummy_2',
'current_period_end': '1970-05-23T12:05:21Z', 'status': 'subscription_initiated',
'customer': 1, 'subscription_state': 'notStarted'}
]
@mock.patch(UTILS_MODULE + ".get_subscription_api_client")
@mock.patch(UTILS_MODULE + ".log.info")
def test_get_programs_subscription_data(self, mock_log, mock_get_subscription_api_client):
# mock return values
mock_client = mock.Mock()
mock_get_subscription_api_client.return_value = mock_client
mock_response = {"results": self.mock_program_subscription_data, "next": None}
mock_client.get.return_value = mock.Mock(json=lambda: mock_response, raise_for_status=lambda: None)
# call the function
user = mock.Mock()
result = get_programs_subscription_data(user)
# assert expected behavior
mock_log.assert_called_once_with(f"B2C_SUBSCRIPTIONS: Requesting Program subscription data for user: {user}")
mock_get_subscription_api_client.assert_called_once_with(user)
mock_client.get.assert_called_once_with(settings.SUBSCRIPTIONS_API_PATH, params={"page": 1})
assert result == self.mock_program_subscription_data
@mock.patch(UTILS_MODULE + ".get_subscription_api_client")
@mock.patch(UTILS_MODULE + ".log.info")
def test_get_programs_subscription_data_with_uuid(self, mock_log, mock_get_subscription_api_client):
mock_client = mock.Mock()
mock_get_subscription_api_client.return_value = mock_client
subscription_data = self.mock_program_subscription_data[0]
program_uuid = subscription_data['resource_id']
mock_response = {"results": subscription_data, "next": None}
mock_client.get.return_value = mock.Mock(json=lambda: mock_response, raise_for_status=lambda: None)
user = mock.Mock()
result = get_programs_subscription_data(user, program_uuid=program_uuid)
mock_log.assert_called_once_with(f"B2C_SUBSCRIPTIONS: Requesting Program subscription data for user: {user}"
f" for program_uuid: {str(program_uuid)}")
mock_get_subscription_api_client.assert_called_once_with(user)
mock_client.get.assert_called_once_with(
settings.SUBSCRIPTIONS_API_PATH,
params={
"most_active_and_recent": 'true',
"resource_id": program_uuid,
}
)
assert result == subscription_data
@override_settings(SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL='http://subscription_buy_url/')
@ddt.ddt
class TestBuySubscriptionUrl(TestCase):
"""
Tests for the BuySubscriptionUrl utility function.
"""
@ddt.data(
{
'skus': ['TESTSKU'],
'program_uuid': '12345678-9012-3456-7890-123456789012'
},
{
'skus': ['TESTSKU1', 'TESTSKU2', 'TESTSKU3'],
'program_uuid': '12345678-9012-3456-7890-123456789012'
},
{
'skus': [],
'program_uuid': '12345678-9012-3456-7890-123456789012'
}
)
@ddt.unpack
def test_get_buy_subscription_url(self, skus, program_uuid):
""" Verify the subscription purchase page URL is properly constructed and returned. """
url = get_buy_subscription_url(program_uuid, skus)
formatted_skus = urlencode({'sku': skus}, doseq=True)
expected_url = f'{settings.SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL}{program_uuid}/?{formatted_skus}'
assert url == expected_url

View File

@@ -5,9 +5,8 @@ import logging
from collections import defaultdict
from copy import deepcopy
from itertools import chain
from urllib.parse import urlencode, urljoin, urlparse, urlunparse
from urllib.parse import urljoin, urlparse, urlunparse
import requests
from dateutil.parser import parse
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -15,7 +14,6 @@ from django.contrib.sites.models import Site
from django.core.cache import cache
from django.urls import reverse
from django.utils.functional import cached_property
from edx_rest_api_client.auth import SuppliedJwtAuth
from opaque_keys.edx.keys import CourseKey
from pytz import utc
from requests.exceptions import RequestException
@@ -42,7 +40,6 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi
from openedx.core.djangoapps.credentials.utils import get_credentials, get_credentials_records_url
from openedx.core.djangoapps.enrollments.api import get_enrollments
from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.djangoapps.programs import ALWAYS_CALCULATE_PROGRAM_PRICE_AS_ANONYMOUS_USER
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from xmodule.modulestore.django import modulestore
@@ -64,15 +61,6 @@ def get_program_and_course_data(site, user, program_uuid, mobile_only=False):
return program_data, course_data
def get_buy_subscription_url(program_uuid, skus):
"""
Returns the URL to the Subscription Purchase page for the given program UUID and course Skus.
"""
formatted_skus = urlencode({"sku": skus}, doseq=True)
url = f"{settings.SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL}{program_uuid}/?{formatted_skus}"
return url
def get_program_urls(program_data):
"""Returns important urls of program."""
from lms.djangoapps.learner_dashboard.utils import FAKE_COURSE_KEY, strip_course_id
@@ -92,10 +80,6 @@ def get_program_urls(program_data):
"commerce_api_url": reverse("commerce_api:v0:baskets:create"),
"buy_button_url": ecommerce_service.get_checkout_page_url(*skus),
"program_record_url": program_record_url,
"buy_subscription_url": get_buy_subscription_url(program_uuid, skus),
"manage_subscription_url": settings.SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL,
"orders_and_subscriptions_url": settings.ORDER_HISTORY_MICROFRONTEND_URL,
"subscriptions_learner_help_center_url": settings.SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL,
}
return urls
@@ -129,15 +113,6 @@ def get_program_marketing_url(programs_config, mobile_only=False):
return marketing_url
def get_program_subscriptions_marketing_url():
"""Build a URL used to link to subscription eligible programs on the marketing site."""
marketing_urls = settings.MKTG_URLS
return urljoin(
marketing_urls.get("ROOT"),
marketing_urls.get("PROGRAM_SUBSCRIPTIONS"),
)
def attach_program_detail_url(programs, mobile_only=False):
"""Extend program representations by attaching a URL to be used when linking to program details.
@@ -1042,51 +1017,3 @@ def is_user_enrolled_in_program_type(
elif course_run_id in course_runs:
return True
return False
def get_subscription_api_client(user):
"""
Returns an API client which can be used to make Subscriptions API requests.
"""
scopes = ["user_id", "email", "profile"]
jwt = create_jwt_for_user(user, scopes=scopes)
client = requests.Session()
client.auth = SuppliedJwtAuth(jwt)
return client
def get_programs_subscription_data(user, program_uuid=None):
"""
Returns the subscription data for a user's program if uuid is specified
else return data for user's all subscriptions.
"""
client = get_subscription_api_client(user)
api_path = f"{settings.SUBSCRIPTIONS_API_PATH}"
subscription_data = []
log.info(
f"B2C_SUBSCRIPTIONS: Requesting Program subscription data for user: {user}"
+ (f" for program_uuid: {program_uuid}" if program_uuid is not None else "")
)
try:
if program_uuid:
response = client.get(api_path, params={"resource_id": program_uuid, "most_active_and_recent": "true"})
response.raise_for_status()
subscription_data = response.json().get("results", [])
else:
next_page = 1
while next_page:
response = client.get(api_path, params=dict(page=next_page))
response.raise_for_status()
subscription_data.extend(response.json().get("results", []))
next_page = response.json().get("next")
except Exception as exc: # pylint: disable=broad-except
log.exception(
f"B2C_SUBSCRIPTIONS: Failed to retrieve Program Subscription Data for user: {user} with error: {exc}"
+ f" for program_uuid: {str(program_uuid)}"
if program_uuid is not None
else ""
)
return subscription_data