@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -17,10 +17,8 @@ describe('Course Card View', () => {
|
||||
programData,
|
||||
collectionCourseStatus,
|
||||
courseData: {},
|
||||
subscriptionData: [],
|
||||
urls: {},
|
||||
userPreferences: {},
|
||||
isSubscriptionEligible: false,
|
||||
};
|
||||
|
||||
if (typeof collectionCourseStatus === 'undefined') {
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -61,8 +61,3 @@
|
||||
</picture>
|
||||
</div>
|
||||
</a>
|
||||
<% if (isSubscribed) { %>
|
||||
<div class="subscription-badge">
|
||||
<span class="badge badge-light"><%- gettext('Subscribed') %></span>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
@@ -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}
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('—') }
|
||||
) %>
|
||||
</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>
|
||||
@@ -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>
|
||||
<% } %>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user