From e973266b2f3fc01d3ae0d23cd8b1333ed3aaff56 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan <60315450+aht007@users.noreply.github.com> Date: Fri, 14 Apr 2023 16:33:12 +0500 Subject: [PATCH] feat: fetch program subscription details (#32023) * feat: fetch program subscription details --- lms/djangoapps/commerce/utils.py | 2 +- .../learner_dashboard/config/waffle.py | 21 ++++- lms/djangoapps/learner_dashboard/programs.py | 22 +++-- lms/djangoapps/learner_dashboard/utils.py | 23 ++++- lms/envs/common.py | 7 ++ lms/envs/devstack.py | 7 ++ lms/envs/test.py | 7 ++ .../djangoapps/programs/tests/test_utils.py | 85 +++++++++++++++++-- openedx/core/djangoapps/programs/utils.py | 61 ++++++++++++- 9 files changed, 216 insertions(+), 19 deletions(-) diff --git a/lms/djangoapps/commerce/utils.py b/lms/djangoapps/commerce/utils.py index c296ce753c..7c73aae883 100644 --- a/lms/djangoapps/commerce/utils.py +++ b/lms/djangoapps/commerce/utils.py @@ -17,7 +17,7 @@ from common.djangoapps.course_modes.models import CourseMode from openedx.core.djangoapps.commerce.utils import ( get_ecommerce_api_base_url, get_ecommerce_api_client, - is_commerce_service_configured, + is_commerce_service_configured ) from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming import helpers as theming_helpers diff --git a/lms/djangoapps/learner_dashboard/config/waffle.py b/lms/djangoapps/learner_dashboard/config/waffle.py index af0c8b3738..2195a26972 100644 --- a/lms/djangoapps/learner_dashboard/config/waffle.py +++ b/lms/djangoapps/learner_dashboard/config/waffle.py @@ -9,7 +9,7 @@ from edx_toggles.toggles import WaffleFlag # .. toggle_implementation: WaffleFlag # .. toggle_default: False # .. toggle_description: Waffle flag to enable new Program discussion experience in tab view for course. -# This flag is used to decide weather we need to render program data in "tab" view or simple view. +# This flag is used to decide whether we need to render program data in "tab" view or simple view. # In the new tab view, we have tabs like "journey", "live", "discussions" # .. toggle_use_cases: temporary, open_edx # .. toggle_creation_date: 2021-08-25 @@ -26,7 +26,7 @@ ENABLE_PROGRAM_TAB_VIEW = WaffleFlag( # .. toggle_implementation: WaffleFlag # .. toggle_default: False # .. toggle_description: Waffle flag to enable new Masters Program discussion experience for masters program. -# This flag is used to decide weather we need to render master program data in "tab" view or simple view. +# This flag is used to decide whether we need to render master program data in "tab" view or simple view. # In the new tab view, we have tabs like "journey", "live", "discussions" # .. toggle_use_cases: temporary, open_edx # .. toggle_creation_date: 2021-10-19 @@ -37,3 +37,20 @@ 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__, +) diff --git a/lms/djangoapps/learner_dashboard/programs.py b/lms/djangoapps/learner_dashboard/programs.py index c4173da214..e4cd0b1c54 100644 --- a/lms/djangoapps/learner_dashboard/programs.py +++ b/lms/djangoapps/learner_dashboard/programs.py @@ -17,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 program_tab_view_is_enabled +from lms.djangoapps.learner_dashboard.utils import program_tab_view_is_enabled, user_b2c_subscriptions_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 ( @@ -28,10 +28,11 @@ from openedx.core.djangoapps.programs.models import ( from openedx.core.djangoapps.programs.utils import ( ProgramProgressMeter, get_certificates, - get_program_marketing_url, get_industry_and_credit_pathways, + get_program_and_course_data, + get_program_marketing_url, get_program_urls, - get_program_and_course_data + get_programs_subscription_data ) from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences from openedx.core.djangolib.markup import HTML @@ -57,11 +58,15 @@ class ProgramsFragmentView(EdxFragmentView): raise Http404 meter = ProgramProgressMeter(request.site, user, mobile_only=mobile_only) + is_user_b2c_subscriptions_enabled = user_b2c_subscriptions_enabled(user, mobile_only) + programs_subscription_data = get_programs_subscription_data(user) 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() + 'progress': meter.progress(), + 'programs_subscription_data': programs_subscription_data, + 'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled } html = render_to_string('learner_dashboard/programs_fragment.html', context) programs_fragment = Fragment(html) @@ -114,6 +119,12 @@ class ProgramDetailsFragmentView(EdxFragmentView): program_discussion_lti = ProgramDiscussionLTI(program_uuid, request) program_live_lti = ProgramLiveLTI(program_uuid, request) + is_user_b2c_subscriptions_enabled = user_b2c_subscriptions_enabled(user, 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 ( @@ -127,6 +138,8 @@ class ProgramDetailsFragmentView(EdxFragmentView): 'urls': urls, 'user_preferences': get_user_preferences(user), 'program_data': program_data, + 'program_subscription_data': program_subscription_data, + 'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled, 'course_data': course_data, 'certificate_data': certificate_data, 'industry_pathways': industry_pathways, @@ -141,7 +154,6 @@ class ProgramDetailsFragmentView(EdxFragmentView): 'iframe': program_live_lti.render_iframe() } } - html = render_to_string('learner_dashboard/program_details_fragment.html', context) program_details_fragment = Fragment(html) self.add_fragment_resource_urls(program_details_fragment) diff --git a/lms/djangoapps/learner_dashboard/utils.py b/lms/djangoapps/learner_dashboard/utils.py index 6f00c1f057..244f0d5603 100644 --- a/lms/djangoapps/learner_dashboard/utils.py +++ b/lms/djangoapps/learner_dashboard/utils.py @@ -6,8 +6,13 @@ from django.core.exceptions import ObjectDoesNotExist from opaque_keys.edx.keys import CourseKey from common.djangoapps.student.roles import GlobalStaff -from lms.djangoapps.learner_dashboard.config.waffle import ENABLE_MASTERS_PROGRAM_TAB_VIEW, ENABLE_PROGRAM_TAB_VIEW +from lms.djangoapps.learner_dashboard.config.waffle import ( + ENABLE_B2C_SUBSCRIPTIONS, + ENABLE_MASTERS_PROGRAM_TAB_VIEW, + ENABLE_PROGRAM_TAB_VIEW +) from lms.djangoapps.program_enrollments.api import get_program_enrollment +from openedx.features.enterprise_support.utils import is_enterprise_learner FAKE_COURSE_KEY = CourseKey.from_string('course-v1:fake+course+run') @@ -46,3 +51,19 @@ 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 user_b2c_subscriptions_enabled(user, is_mobile=False) -> bool: + """ + Check if user is eligible to see B2C subscriptions. + """ + if not is_mobile and not is_enterprise_learner(user) and b2c_subscriptions_is_enabled(): + return True + return False diff --git a/lms/envs/common.py b/lms/envs/common.py index eb69ac6b01..64d2465107 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5306,3 +5306,10 @@ ENTERPRISE_PLOTLY_SECRET = "I am a secret" 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/" diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index c2454ee71f..5106ec1202 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -497,6 +497,13 @@ EVENT_BUS_KAFKA_SCHEMA_REGISTRY_URL = 'http://edx.devstack.schema-registry:8081' EVENT_BUS_KAFKA_BOOTSTRAP_SERVERS = 'edx.devstack.kafka:29092' EVENT_BUS_TOPIC_PREFIX = 'dev' +######################## 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/" + ################# New settings must go ABOVE this line ################# ######################################################################## # See if the developer has any local overrides. diff --git a/lms/envs/test.py b/lms/envs/test.py index b1fb5f8b24..707429f093 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -676,3 +676,10 @@ MFE_CONFIG_OVERRIDES = { SURVEY_REPORT_EXTRA_DATA = {} SURVEY_REPORT_ENDPOINT = "https://example.com/survey_report" 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/" diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index 8b0914ecaa..52787d02af 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -8,27 +8,25 @@ from copy import deepcopy from unittest import mock import ddt -from edx_toggles.toggles.testutils import override_waffle_switch import httpretty from django.conf import settings from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_switch +from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=wrong-import-order from pytz import utc from testfixtures import LogCapture -from xmodule.data import CertificatesDisplayBehaviors -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.entitlements.tests.factories import CourseEntitlementFactory +from common.djangoapps.student.tests.factories import AnonymousUserFactory, CourseEnrollmentFactory, UserFactory +from common.djangoapps.util.date_utils import strftime_localized from lms.djangoapps.certificates.api import MODES from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.commerce.tests.test_utils import update_commerce_config from lms.djangoapps.commerce.utils import EcommerceService -from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=wrong-import-order from openedx.core.djangoapps.catalog.tests.factories import ( CourseFactory, CourseRunFactory, @@ -47,12 +45,15 @@ from openedx.core.djangoapps.programs.utils import ( ProgramProgressMeter, 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 from openedx.core.djangolib.testing.utils import skip_unless_lms -from common.djangoapps.student.tests.factories import AnonymousUserFactory, CourseEnrollmentFactory, UserFactory -from common.djangoapps.util.date_utils import strftime_localized +from xmodule.data import CertificatesDisplayBehaviors +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory ECOMMERCE_URL_ROOT = 'https://ecommerce.example.com' UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' @@ -1729,3 +1730,71 @@ 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', + 'next_payment_date': '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', + 'next_payment_date': '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 diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 39b8e60392..b96609644f 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -8,6 +8,7 @@ from copy import deepcopy from itertools import chain 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,10 +16,10 @@ 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 -from xmodule.modulestore.django import modulestore from common.djangoapps.course_modes.api import get_paid_modes_for_course from common.djangoapps.course_modes.models import CourseMode @@ -35,15 +36,17 @@ from openedx.core.djangoapps.catalog.constants import PathwayType from openedx.core.djangoapps.catalog.utils import ( get_fulfillable_course_runs_for_entitlement, get_pathways, - get_programs, + get_programs ) from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client from openedx.core.djangoapps.content.course_overviews.models import CourseOverview 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 # The datetime module's strftime() methods require a year >= 1900. DEFAULT_ENROLLMENT_START_DATE = datetime.datetime(1900, 1, 1, tzinfo=utc) @@ -82,6 +85,9 @@ 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': settings.SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL, + 'manage_subscription_url': settings.ORDER_HISTORY_MICROFRONTEND_URL, + 'subscriptions_learner_help_center_url': settings.SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL } return urls @@ -151,6 +157,7 @@ class ProgramProgressMeter: will only inspect this one program, not all programs the user may be engaged with. """ + def __init__(self, site, user, enrollments=None, uuid=None, mobile_only=False, include_course_entitlements=True): self.site = site self.user = user @@ -544,6 +551,7 @@ class ProgramDataExtender: program_data (dict): Representation of a program. user (User): The user whose enrollments to inspect. """ + def __init__(self, program_data, user, mobile_only=False): self.data = program_data self.user = user @@ -865,6 +873,7 @@ class ProgramMarketingDataExtender(ProgramDataExtender): program_data (dict): Representation of a program. user (User): The user whose enrollments to inspect. """ + def __init__(self, program_data, user): super().__init__(program_data, user) @@ -1028,3 +1037,51 @@ def is_user_enrolled_in_program_type(user, program_type_slug, paid_modes_only=Fa 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