Skip to content

Commit

Permalink
feat: inject a failure reason into the failure_url query params when …
Browse files Browse the repository at this point in the history
…a verified course mode is not available for DSC-based enrollment. ENT-4564
iloveagent57 authored and adamstankiewicz committed Sep 7, 2021
1 parent 289ae6a commit 4da39a4
Showing 6 changed files with 210 additions and 43 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -18,6 +18,11 @@ Unreleased
* Nothing


[3.28.1]
---------
* Inject a failure reason into the ``failure_url`` query params when a verified course mode
is not available for DSC-based enrollment.

[3.28.0]
---------
* Added support for Django 3.0, 3.1 and 3.2
133 changes: 94 additions & 39 deletions enterprise/views.py
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@
import datetime
import json
import re
from collections import namedtuple
from logging import getLogger
from uuid import UUID

@@ -109,7 +110,8 @@
LMS_LOGIN_URL = urljoin(settings.LMS_ROOT_URL, '/login')
ENTERPRISE_GENERAL_ERROR_PAGE = 'enterprise/enterprise_error_page_with_messages.html'


# Constants used for logging errors that occur during
# the Data-sharing consent flow
CATALOG_API_CONFIG_ERROR_CODE = 'ENTGDS004'
CUSTOMER_DOES_NOT_EXIST_ERROR_CODE = 'ENTGDS008'
CONTENT_ID_DOES_NOT_EXIST_ERROR_CODE = 'ENTGDS000'
@@ -118,6 +120,8 @@
REDIRECT_URLS_MISSING_ERROR_CODE = 'ENTGDS003'
COURSE_MODE_DOES_NOT_EXIST_ERROR_CODE = 'ENTHCE000'
ROUTER_VIEW_NO_COURSE_ID_ERROR_CODE = 'ENTRV000'
VERIFIED_MODE_UNAVAILABLE_ERROR_CODE = 'ENTGDS110'
ENROLLMENT_INTEGRITY_ERROR_CODE = 'ENTGDS009'

DSC_ERROR_MESSAGES_BY_CODE = {
CATALOG_API_CONFIG_ERROR_CODE: 'Course catalog api configuration error.',
@@ -128,6 +132,8 @@
REDIRECT_URLS_MISSING_ERROR_CODE: 'Required request values missing for action to be carried out.',
COURSE_MODE_DOES_NOT_EXIST_ERROR_CODE: 'Course_modes for course not found.',
ROUTER_VIEW_NO_COURSE_ID_ERROR_CODE: 'In Router View, could not find course run with given id.',
VERIFIED_MODE_UNAVAILABLE_ERROR_CODE: 'The [verified] course mode is expired or otherwise unavailable',
ENROLLMENT_INTEGRITY_ERROR_CODE: 'IntegrityError while creating EnterpriseCourseEnrollment.',
}


@@ -149,6 +155,31 @@ def _log_error_message(error_code, exception=None, logger_method=LOGGER.error, *
return log_message


# The query param used to indicate some reason for enrollment failure;
# added to the `failure_url` that can be redirected to during the
# DSC enrollment flow.
FAILED_ENROLLMENT_REASON_QUERY_PARAM = 'failure_reason'


class VerifiedModeUnavailableException(Exception):
"""
Exception that indicates the verified enrollment mode
has expired or is otherwise unavaible for a course run.
"""


FailedEnrollmentReason = namedtuple(
'FailedEnrollmentReason',
['enrollment_client_error', 'failure_reason_message']
)


VERIFIED_MODE_UNAVAILABLE = FailedEnrollmentReason(
enrollment_client_error='The [verified] course mode is expired or otherwise unavailable',
failure_reason_message='verified_mode_unavailable',
)


def verify_edx_resources():
"""
Ensure that all necessary resources to render the view are present.
@@ -241,6 +272,18 @@ def should_upgrade_to_licensed_enrollment(consent_record, license_uuid):
return consent_record is not None and consent_record.granted and license_uuid


def add_reason_to_failure_url(base_failure_url, failure_reason):
"""
Adds a query param to the given ``base_failure_url`` indicating
why an enrollment has failed.
"""
(scheme, netloc, path, query, fragment) = list(urlsplit(base_failure_url))
query_dict = parse_qs(query)
query_dict[FAILED_ENROLLMENT_REASON_QUERY_PARAM] = failure_reason
new_query = urlencode(query_dict, doseq=True)
return urlunsplit((scheme, netloc, path, new_query, fragment))


class NonAtomicView(View):
"""
A base class view for views that disable atomicity in requests.
@@ -615,7 +658,11 @@ def _enroll_learner_in_course(
existing_enrollment = enrollment_api_client.get_course_enrollment(
request.user.username, course_id
)
if not existing_enrollment or existing_enrollment.get('mode') == constants.CourseModes.AUDIT:
if (
not existing_enrollment or
existing_enrollment.get('mode') == constants.CourseModes.AUDIT or
existing_enrollment.get('is_active') is False
):
course_mode = get_best_mode_from_course_key(course_id)
LOGGER.info(
'Retrieved Course Mode: {course_modes} for Course {course_id}'.format(
@@ -654,37 +701,36 @@ def _enroll_learner_in_course(
'message': error_message,
}
)
raise
if VERIFIED_MODE_UNAVAILABLE.enrollment_client_error in error_message:
raise VerifiedModeUnavailableException(error_message) from exc
raise Exception(error_message) from exc
try:
self.create_enterprise_course_enrollment(request, enterprise_customer, course_id, license_uuid)
except IntegrityError:
error_code = 'ENTGDS009'
log_message = (
'[Enterprise DSC API] IntegrityError while creating EnterpriseCourseEnrollment.'
'Course: {course_id}, '
'Program: {program_uuid}, '
'EnterpriseCustomer: {enterprise_customer_uuid}, '
'User: {user_id}, '
'License UUID: {license_uuid}, '
'ErrorCode: {error_code}'.format(
course_id=course_id,
program_uuid=program_uuid,
enterprise_customer_uuid=enterprise_customer.uuid,
user_id=request.user.id,
license_uuid=license_uuid,
error_code=error_code,
)
except IntegrityError as exc:
_log_error_message(
ENROLLMENT_INTEGRITY_ERROR_CODE, exception=exc,
course_id=course_id, program_uuid=program_uuid,
enterprise_customer_uuid=enterprise_customer.uuid,
user_id=request.user.id, license_uuid=license_uuid,
)
LOGGER.exception(log_message)

def _upgrade_to_licensed_enrollment(
def _do_enrollment_and_redirect(
self, request, enterprise_customer,
course_id, program_uuid, license_uuid,
success_url, failure_url,
success_url, failure_url, consent_record=None, consent_provided=True,
):
"""
Helper to create a licensed enrollment.
Helper to enroll a learner into a course, handle an expected error about
verified course modes not being available, and return an appropriate
redirect url.
"""
error_message_kwargs = {
'course_id': course_id,
'program_uuid': program_uuid,
'enterprise_customer_uuid': enterprise_customer.uuid,
'user_id': request.user.id,
'license_uuid': license_uuid,
}
try:
self._enroll_learner_in_course(
request=request,
@@ -693,12 +739,27 @@ def _upgrade_to_licensed_enrollment(
program_uuid=program_uuid,
license_uuid=license_uuid,
)
if consent_record:
consent_record.granted = consent_provided
consent_record.save()
return redirect(success_url)
except VerifiedModeUnavailableException as exc:
_log_error_message(
error_code=VERIFIED_MODE_UNAVAILABLE_ERROR_CODE,
exception=exc,
**error_message_kwargs,
)
return redirect(
add_reason_to_failure_url(
failure_url,
VERIFIED_MODE_UNAVAILABLE.failure_reason_message,
)
)
except Exception as exc: # pylint: disable=broad-except
_log_error_message(
error_code=LICENSED_ENROLLMENT_ERROR_CODE, exception=exc, course_id=course_id,
program_uuid=program_uuid, enterprise_customer_uuid=enterprise_customer.uuid,
user_id=request.user.id, license_uuid=license_uuid,
error_code=LICENSED_ENROLLMENT_ERROR_CODE,
exception=exc,
**error_message_kwargs,
)
return redirect(failure_url)

@@ -757,7 +818,7 @@ def get(self, request):
not enterprise_customer.requests_data_sharing_consent or
should_upgrade_to_licensed_enrollment(consent_record, license_uuid)
):
return self._upgrade_to_licensed_enrollment(
return self._do_enrollment_and_redirect(
request, enterprise_customer,
course_id, program_uuid, license_uuid,
success_url, failure_url,
@@ -865,17 +926,11 @@ def post(self, request):
defer_creation = request.POST.get('defer_creation')
consent_provided = bool(request.POST.get('data_sharing_consent', False))
if defer_creation is None and consent_record.consent_required() and consent_provided:
try:
self._enroll_learner_in_course(
request=request,
enterprise_customer=enterprise_customer,
course_id=course_id,
program_uuid=program_uuid,
license_uuid=license_uuid)
consent_record.granted = consent_provided
consent_record.save()
except Exception: # pylint: disable=broad-except
return redirect(failure_url)
return self._do_enrollment_and_redirect(
request, enterprise_customer,
course_id, program_uuid, license_uuid,
success_url, failure_url, consent_record=consent_record,
)

return redirect(success_url if consent_provided else failure_url)

2 changes: 1 addition & 1 deletion test_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -203,7 +203,7 @@ def update_search_with_enterprise_context(search_result, add_utm_info):
return search_result


def fake_render(request, template, context): # pylint: disable=unused-argument
def fake_render(request, template, context, **kwargs): # pylint: disable=unused-argument
"""
Switch the request to use a template that does not depend on edx-platform.
The choice of the template here is arbitrary, as long as it renders successfully for tests.
107 changes: 107 additions & 0 deletions tests/test_enterprise/views/test_grant_data_sharing_permissions.py
Original file line number Diff line number Diff line change
@@ -3,11 +3,13 @@
Tests for the ``GrantDataSharingPermissions`` view of the Enterprise app.
"""

import json
import uuid

import ddt
import mock
from dateutil.parser import parse
from edx_rest_api_client.exceptions import HttpClientError
from pytest import mark

from django.conf import settings
@@ -17,6 +19,7 @@
from django.urls import reverse

from enterprise.models import EnterpriseCourseEnrollment, LicensedEnterpriseCourseEnrollment
from enterprise.views import FAILED_ENROLLMENT_REASON_QUERY_PARAM, VERIFIED_MODE_UNAVAILABLE, add_reason_to_failure_url
from integrated_channels.exceptions import ClientError
from test_utils import fake_render
from test_utils.factories import (
@@ -42,6 +45,9 @@ def setUp(self):
self.user.set_password("QWERTY")
self.user.save()
self.client = Client()

LicensedEnterpriseCourseEnrollment.objects.all().delete()
EnterpriseCourseEnrollment.objects.all().delete()
super().setUp()

url = reverse('grant_data_sharing_permissions')
@@ -885,6 +891,107 @@ def test_post_course_specific_consent_bad_api_response(
dsc.refresh_from_db()
assert dsc.granted is False

def test_add_reason_to_failure_url(self):
base_failure_url = 'https://example.com/?enrollment_failed=true'
failure_reason = 'something weird happened'

actual_url = add_reason_to_failure_url(base_failure_url, failure_reason)
expected_url = (
'https://example.com/?enrollment_failed=true&'
'{reason_param}=something+weird+happened'.format(
reason_param=FAILED_ENROLLMENT_REASON_QUERY_PARAM,
)
)
self.assertEqual(actual_url, expected_url)

@mock.patch('enterprise.views.reverse', return_value='/dashboard')
@mock.patch('enterprise.views.render', side_effect=fake_render)
@mock.patch('enterprise.views.get_best_mode_from_course_key')
@mock.patch('enterprise.models.EnterpriseCatalogApiClient')
@mock.patch('enterprise.views.EnrollmentApiClient')
@mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient')
@ddt.data(True, False)
def test_get_dsc_verified_mode_unavailable(
self,
is_post,
mock_course_catalog_api_client,
mock_enrollment_api_client,
mock_enterprise_catalog_client,
mock_get_course_mode,
*args,
):
self._login()
course_id = 'course-v1:edX+DemoX+Demo_Course'
license_uuid = str(uuid.uuid4())

enterprise_customer = EnterpriseCustomerFactory(
name='Starfleet Academy',
enable_data_sharing_consent=True,
enforce_data_sharing_consent='at_enrollment',
)
EnterpriseCustomerCatalogFactory(
enterprise_customer=enterprise_customer,
content_filter={'key': [course_id]},
)
ecu = EnterpriseCustomerUserFactory(
user_id=self.user.id,
enterprise_customer=enterprise_customer
)
DataSharingConsentFactory(
username=self.user.username,
course_id=course_id,
enterprise_customer=enterprise_customer,
granted=not is_post,
)

mock_course_catalog_api_client.return_value.program_exists.return_value = True
mock_course_catalog_api_client.return_value.get_course_id.return_value = course_id

enterprise_catalog_client = mock_enterprise_catalog_client.return_value
enterprise_catalog_client.enterprise_contains_content_items.return_value = True

course_mode = 'verified'
mock_get_course_mode.return_value = course_mode

mock_get_enrollment = mock_enrollment_api_client.return_value.get_course_enrollment
mock_get_enrollment.return_value = None

mock_enroll_user = mock_enrollment_api_client.return_value.enroll_user_in_course
client_error_content = json.dumps(
{'message': VERIFIED_MODE_UNAVAILABLE.enrollment_client_error}
).encode()
mock_enroll_user.side_effect = HttpClientError(content=client_error_content)

params = {
'enterprise_customer_uuid': str(enterprise_customer.uuid),
'course_id': course_id,
'next': 'https://success.url',
'redirect_url': 'https://success.url',
'failure_url': 'https://failure.url',
'license_uuid': license_uuid,
'data_sharing_consent': True,
}
if is_post:
response = self.client.post(self.url, data=params)
else:
response = self.client.get(self.url, data=params)

assert response.status_code == 302
self.assertRedirects(
response,
'https://failure.url?failure_reason={}'.format(VERIFIED_MODE_UNAVAILABLE.failure_reason_message),
fetch_redirect_response=False,
)

assert EnterpriseCourseEnrollment.objects.filter(
enterprise_customer_user=ecu,
course_id=course_id,
).exists() is False

assert LicensedEnterpriseCourseEnrollment.objects.filter(
license_uuid=license_uuid,
).exists() is False


@mark.django_db
@ddt.ddt
2 changes: 1 addition & 1 deletion tests/test_integrated_channels/test_canvas/test_utils.py
Original file line number Diff line number Diff line change
@@ -61,7 +61,7 @@ def test_find_root_canvas_account_found(self):
return_value=success_response
)
root_account = CanvasUtil.find_root_canvas_account(self.enterprise_config, mock_session)
assert root_account['id'] == 1
assert root_account['id'] == 1 # pylint: disable=unsubscriptable-object

def test_find_root_canvas_account_not_found(self):
success_response = unittest.mock.Mock(spec=Response)
4 changes: 2 additions & 2 deletions tests/test_integrated_channels/test_xapi/test_utils.py
Original file line number Diff line number Diff line change
@@ -79,7 +79,7 @@ def test_send_course_enrollment_statement(self, mock_get_user_social_auth, *args
{'status': 500, 'error_messages': None},
)

self.x_api_client.lrs.save_statement.assert_called()
self.x_api_client.lrs.save_statement.assert_called() # pylint: disable=no-member

@mock.patch('integrated_channels.xapi.client.RemoteLRS', mock.MagicMock())
@mock.patch('enterprise.api_client.discovery.JwtBuilder')
@@ -139,7 +139,7 @@ def test_send_course_completion_statement(self, mock_get_user_social_auth, *args
{'status': 500, 'error_message': None}
)

self.x_api_client.lrs.save_statement.assert_called()
self.x_api_client.lrs.save_statement.assert_called() # pylint: disable=no-member

def test_is_success_response(self):
"""

0 comments on commit 4da39a4

Please sign in to comment.