diff --git a/lms/djangoapps/courseware/admin.py b/lms/djangoapps/courseware/admin.py index a7bfb2a512..573286ccc2 100644 --- a/lms/djangoapps/courseware/admin.py +++ b/lms/djangoapps/courseware/admin.py @@ -8,6 +8,8 @@ from django.contrib import admin from lms.djangoapps.courseware import models + +admin.site.register(models.FinancialAssistanceConfiguration, ConfigurationModelAdmin) admin.site.register(models.DynamicUpgradeDeadlineConfiguration, ConfigurationModelAdmin) admin.site.register(models.OfflineComputedGrade) admin.site.register(models.OfflineComputedGradeLog) diff --git a/lms/djangoapps/courseware/constants.py b/lms/djangoapps/courseware/constants.py new file mode 100644 index 0000000000..dcde5f7024 --- /dev/null +++ b/lms/djangoapps/courseware/constants.py @@ -0,0 +1,8 @@ +""" +Constants for courseware app. +""" +UNEXPECTED_ERROR_IS_ELIGIBLE = "An unexpected error occurred while fetching " \ + "financial assistance eligibility criteria for a course" +UNEXPECTED_ERROR_APPLICATION_STATUS = "An unexpected error occurred while getting " \ + "financial assistance application status" +UNEXPECTED_ERROR_CREATE_APPLICATION = "An unexpected error occurred while creating financial assistance application" diff --git a/lms/djangoapps/courseware/migrations/0017_financialassistanceconfiguration.py b/lms/djangoapps/courseware/migrations/0017_financialassistanceconfiguration.py new file mode 100644 index 0000000000..857258f5b9 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0017_financialassistanceconfiguration.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.12 on 2022-04-11 19:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('courseware', '0016_lastseencoursewaretimezone'), + ] + + operations = [ + migrations.CreateModel( + name='FinancialAssistanceConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('api_base_url', models.URLField(help_text='Financial Assistance Backend API Base URL.', verbose_name='Internal API Base URL')), + ('service_username', models.CharField(default='financial_assistance_service_user', help_text='Username created for Financial Assistance Backend, e.g. financial_assistance_service_user.', max_length=100)), + ('fa_backend_enabled_courses_percentage', models.IntegerField(default=0, help_text='Percentage of courses allowed to use edx-financial-assistance')), + ('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')), + ], + options={ + 'ordering': ('-change_date',), + 'abstract': False, + }, + ), + ] diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 5aa20a6616..05020a811d 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -18,6 +18,7 @@ import itertools import logging from config_models.models import ConfigurationModel +from django.contrib.auth import get_user_model from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db import models @@ -500,3 +501,33 @@ class LastSeenCoursewareTimezone(models.Model): class Meta: app_label = "courseware" + + +class FinancialAssistanceConfiguration(ConfigurationModel): + """ + Manages configuration for connecting to Financial Assistance backend service and using its API. + """ + + api_base_url = models.URLField( + verbose_name=_('Internal API Base URL'), + help_text=_('Financial Assistance Backend API Base URL.') + ) + + service_username = models.CharField( + max_length=100, + default='financial_assistance_service_user', + null=False, + blank=False, + help_text=_('Username created for Financial Assistance Backend, e.g. financial_assistance_service_user.') + ) + + fa_backend_enabled_courses_percentage = models.IntegerField( + default=0, + help_text=_('Percentage of courses allowed to use edx-financial-assistance') + ) + + def get_service_user(self): + """ + Getter function to get service user for Financial Assistance backend. + """ + return get_user_model().objects.get(username=self.service_username) diff --git a/lms/djangoapps/courseware/tests/factories.py b/lms/djangoapps/courseware/tests/factories.py index e699db9c72..a7343528df 100644 --- a/lms/djangoapps/courseware/tests/factories.py +++ b/lms/djangoapps/courseware/tests/factories.py @@ -17,7 +17,8 @@ from lms.djangoapps.courseware.models import ( StudentModule, XModuleStudentInfoField, XModuleStudentPrefsField, - XModuleUserStateSummaryField + XModuleUserStateSummaryField, + FinancialAssistanceConfiguration ) COURSE_KEY = CourseKey.from_string('edX/test_course/test') @@ -75,3 +76,12 @@ class StudentInfoFactory(DjangoModelFactory): field_name = 'existing_field' value = json.dumps('old_value') student = factory.SubFactory(UserFactory) + + +class FinancialAssistanceConfigurationFactory(DjangoModelFactory): + """ + Factory for FinancialAssistanceConfiguration model. + """ + + class Meta: + model = FinancialAssistanceConfiguration diff --git a/lms/djangoapps/courseware/tests/test_utils.py b/lms/djangoapps/courseware/tests/test_utils.py new file mode 100644 index 0000000000..69d818c99f --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_utils.py @@ -0,0 +1,158 @@ +""" +Unit test for various Utility functions +""" +import json +from unittest.mock import patch + +import ddt +from django.test import TestCase +from edx_rest_api_client.client import OAuthAPIClient +from oauth2_provider.models import Application +from requests.models import Response +from rest_framework import status + +from common.djangoapps.student.tests.factories import GlobalStaffFactory, UserFactory +from lms.djangoapps.courseware.constants import UNEXPECTED_ERROR_IS_ELIGIBLE +from lms.djangoapps.courseware.tests.factories import FinancialAssistanceConfigurationFactory +from lms.djangoapps.courseware.utils import ( + create_financial_assistance_application, + get_financial_assistance_application_status, + is_eligible_for_financial_aid +) + + +@ddt.ddt +class TestFinancialAssistanceViews(TestCase): + """ + Tests new financial assistance views that communicate with edx-financial-assistance backend. + """ + + def setUp(self) -> None: + super().setUp() + self.test_course_id = 'course-v1:edX+Test+1' + self.user = UserFactory() + self.global_staff = GlobalStaffFactory.create() + _ = FinancialAssistanceConfigurationFactory( + api_base_url='http://financial.assistance.test:1234', + service_username=self.global_staff.username, + fa_backend_enabled_courses_percentage=100, + enabled=True + ) + _ = Application.objects.create( + name='Test Application', + user=self.global_staff, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + ) + + def _mock_response(self, status_code, content=None): + """ + Generates a python core response which is used as a default response in edx-rest-api-client. + """ + mock_response = Response() + mock_response.status_code = status_code + mock_response._content = json.dumps(content).encode('utf-8') # pylint: disable=protected-access + return mock_response + + @ddt.data( + {'is_eligible': True, 'reason': None}, + {'is_eligible': False, 'reason': 'This course is not eligible for financial aid'} + ) + def test_is_eligible_for_financial_aid(self, response_data): + """ + Tests the functionality of is_eligible_for_financial_aid which calls edx-financial-assistance backend + to return eligibility status for financial assistance for a given course. + """ + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response(status.HTTP_200_OK, response_data) + is_eligible, reason = is_eligible_for_financial_aid(self.test_course_id) + assert is_eligible is response_data.get('is_eligible') + assert reason == response_data.get('reason') + + def test_is_eligible_for_financial_aid_invalid_course_id(self): + """ + Tests the functionality of is_eligible_for_financial_aid for an invalid course id. + """ + error_message = f"Invalid course id {self.test_course_id} provided" + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response( + status.HTTP_400_BAD_REQUEST, {"message": error_message} + ) + is_eligible, reason = is_eligible_for_financial_aid(self.test_course_id) + assert is_eligible is False + assert reason == error_message + + def test_is_eligible_for_financial_aid_invalid_unexpected_error(self): + """ + Tests the functionality of is_eligible_for_financial_aid for an unexpected error + """ + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response( + status.HTTP_500_INTERNAL_SERVER_ERROR, {'error': 'unexpected error occurred'} + ) + is_eligible, reason = is_eligible_for_financial_aid(self.test_course_id) + assert is_eligible is False + assert reason == UNEXPECTED_ERROR_IS_ELIGIBLE + + def test_get_financial_assistance_application_status(self): + """ + Tests the functionality of get_financial_assistance_application_status against a user id and a course id + edx-financial-assistance backend to return status of a financial assistance application. + """ + test_response = {'id': 123, 'status': 'ACCEPTED', 'coupon_code': 'ABCD..'} + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response(status.HTTP_200_OK, test_response) + has_application, reason = get_financial_assistance_application_status(self.user.id, self.test_course_id) + assert has_application is True + assert reason == test_response + + @ddt.data( + { + 'status': status.HTTP_400_BAD_REQUEST, + 'content': {'message': 'Invalid course id provided'} + }, + { + 'status': status.HTTP_404_NOT_FOUND, + 'content': {'message': 'Application details not found'} + } + ) + def test_get_financial_assistance_application_status_unsuccessful(self, response_data): + """ + Tests unsuccessful scenarios of get_financial_assistance_application_status + against a user id and a course id edx-financial-assistance backend. + """ + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response(response_data.get('status'), response_data.get('content')) + has_application, reason = get_financial_assistance_application_status(self.user.id, self.test_course_id) + assert has_application is False + assert reason == response_data.get('content').get('message') + + @ddt.data( + { + 'status': status.HTTP_400_BAD_REQUEST, + 'content': {'message': 'Invalid course id provided'}, + 'message': 'Invalid course id provided', + 'created': False + }, + { + 'status': status.HTTP_200_OK, + 'content': {'success': True}, + 'message': None, + 'created': True + } + ) + def test_create_financial_assistance_application(self, response_data): + """ + Tests the functionality of create_financial_assistance_application which calls edx-financial-assistance backend + to create a new financial assistance application given a form data. + """ + test_form_data = { + 'lms_user_id': self.user.id, + 'course_id': self.test_course_id, + 'income': '85K_TO_100K' + } + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response(response_data.get('status'), response_data.get('content')) + created, message = create_financial_assistance_application(form_data=test_form_data) + assert created is response_data.get('created') + assert message == response_data.get('message') diff --git a/lms/djangoapps/courseware/utils.py b/lms/djangoapps/courseware/utils.py index 655a78deb6..87afad8cbb 100644 --- a/lms/djangoapps/courseware/utils.py +++ b/lms/djangoapps/courseware/utils.py @@ -2,14 +2,28 @@ import datetime +import hashlib +import logging from django.conf import settings -from lms.djangoapps.commerce.utils import EcommerceService +from edx_rest_api_client.client import OAuthAPIClient +from oauth2_provider.models import Application from pytz import utc # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework import status +from xmodule.partitions.partitions import \ + ENROLLMENT_TRACK_PARTITION_ID # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.partitions.partitions_service import PartitionService # lint-amnesty, pylint: disable=wrong-import-order from common.djangoapps.course_modes.models import CourseMode -from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions_service import PartitionService # lint-amnesty, pylint: disable=wrong-import-order +from lms.djangoapps.commerce.utils import EcommerceService +from lms.djangoapps.courseware.constants import ( + UNEXPECTED_ERROR_APPLICATION_STATUS, + UNEXPECTED_ERROR_CREATE_APPLICATION, + UNEXPECTED_ERROR_IS_ELIGIBLE +) +from lms.djangoapps.courseware.models import FinancialAssistanceConfiguration + +log = logging.getLogger(__name__) def verified_upgrade_deadline_link(user, course=None, course_id=None): @@ -95,3 +109,102 @@ def can_show_verified_upgrade(user, enrollment, course=None): # Show the summary if user enrollment is in which allow user to upsell return enrollment.is_active and enrollment.mode in CourseMode.UPSELL_TO_VERIFIED_MODES + + +def _request_financial_assistance(method, url, params=None, data=None): + """ + An internal function containing common functionality among financial assistance utility function to call + edx-financial-assistance backend with appropriate method, url, params and data. + """ + financial_assistance_configuration = FinancialAssistanceConfiguration.current() + if financial_assistance_configuration.enabled: + oauth_application = Application.objects.get(user=financial_assistance_configuration.get_service_user()) + client = OAuthAPIClient( + settings.LMS_ROOT_URL, + oauth_application.client_id, + oauth_application.client_secret + ) + return client.request( + method, f"{financial_assistance_configuration.api_base_url}{url}", params=params, data=data + ) + else: + return False, 'Financial Assistance configuration is not enabled' + + +def is_eligible_for_financial_aid(course_id): + """ + Sends a get request to edx-financial-assistance to retrieve financial assistance eligibility criteria for a course. + + Returns either True if course is eligible for financial aid or vice versa. + Also returns the reason why the course isn't eligible. + In case of a bad request, returns an error message. + """ + response = _request_financial_assistance('GET', f"{settings.IS_ELIGIBLE_FOR_FINANCIAL_ASSISTANCE_URL}{course_id}/") + if response.status_code == status.HTTP_200_OK: + return response.json().get('is_eligible'), response.json().get('reason') + elif response.status_code == status.HTTP_400_BAD_REQUEST: + return False, response.json().get('message') + else: + log.error('%s %s', UNEXPECTED_ERROR_IS_ELIGIBLE, str(response.content)) + return False, UNEXPECTED_ERROR_IS_ELIGIBLE + + +def get_financial_assistance_application_status(user_id, course_id): + """ + Given the course_id, sends a get request to edx-financial-assistance to retrieve + financial assistance application(s) status for the logged-in user. + """ + request_params = { + 'course_id': course_id, + 'lms_user_id': user_id + } + response = _request_financial_assistance( + 'GET', f"{settings.FINANCIAL_ASSISTANCE_APPLICATION_STATUS_URL}", params=request_params + ) + if response.status_code == status.HTTP_200_OK: + return True, response.json() + elif response.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_404_NOT_FOUND): + return False, response.json().get('message') + else: + log.error('%s %s', UNEXPECTED_ERROR_APPLICATION_STATUS, response.content) + return False, UNEXPECTED_ERROR_APPLICATION_STATUS + + +def create_financial_assistance_application(form_data): + """ + Sends a post request to edx-financial-assistance to create a new application for financial assistance application. + The incoming form_data must have data as given in the example below: + { + "lms_user_id": , + "course_id": , + "income": , + "learner_reasons": , + "learner_goals": , + "learner_plans": + } + TODO: marketing checkmark field will be added in the backend and needs to be updated here. + """ + response = _request_financial_assistance( + 'POST', f"{settings.CREATE_FINANCIAL_ASSISTANCE_APPLICATION_URL}/", data=form_data + ) + if response.status_code == status.HTTP_200_OK: + return True, None + elif response.status_code == status.HTTP_400_BAD_REQUEST: + return False, response.json().get('message') + else: + log.error('%s %s', UNEXPECTED_ERROR_CREATE_APPLICATION, response.content) + return False, UNEXPECTED_ERROR_CREATE_APPLICATION + + +def get_course_hash_value(course_key): + """ + Returns a hash value for the given course key. + If course key is None, function returns an out of bound value which will + never satisfy the fa_backend_enabled_courses_percentage condition + """ + out_of_bound_value = 100 + if course_key: + m = hashlib.md5(str(course_key).encode()) + return int(m.hexdigest(), base=16) % 100 + + return out_of_bound_value diff --git a/lms/envs/common.py b/lms/envs/common.py index aa628225e6..5693c31ddc 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5062,3 +5062,8 @@ DISCUSSION_MODERATION_CLOSE_REASON_CODES = { "duplicate": _("Post is a duplicate"), "off-topic": _("Post is off-topic"), } + +################# Settings for edx-financial-assistance ################# +IS_ELIGIBLE_FOR_FINANCIAL_ASSISTANCE_URL = '/core/api/course_eligibility/' +FINANCIAL_ASSISTANCE_APPLICATION_STATUS_URL = "/core/api/financial_assistance_application/status/" +CREATE_FINANCIAL_ASSISTANCE_APPLICATION_URL = '/core/api/financial_assistance_applications'