Merge pull request #30114 from openedx/aakbar/PROD-2740
feat: add financial assistance configuration model and util functions
This commit is contained in:
@@ -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)
|
||||
|
||||
8
lms/djangoapps/courseware/constants.py
Normal file
8
lms/djangoapps/courseware/constants.py
Normal 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"
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
158
lms/djangoapps/courseware/tests/test_utils.py
Normal file
158
lms/djangoapps/courseware/tests/test_utils.py
Normal 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')
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user