Merge pull request #30114 from openedx/aakbar/PROD-2740

feat: add financial assistance configuration model and util functions
This commit is contained in:
Ali Akbar
2022-04-12 13:02:31 +05:00
committed by GitHub
8 changed files with 363 additions and 4 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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,
},
),
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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')

View File

@@ -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": <user_id>,
"course_id": <course_run_id>,
"income": <income_from_range>,
"learner_reasons": <TEST_LONG_STRING>,
"learner_goals": <TEST_LONG_STRING>,
"learner_plans": <TEST_LONG_STRING>
}
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

View File

@@ -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'