fix: commerce and enrollment error handling and ux (#36612)
Returns HTTP 400 for disallowed enrollments instead of HTTP 500. Prevents infinite loading spinners on the enrollment page. Displays clear error messages to users before redirection. Ensures consistent and meaningful responses from enrollment API endpoints. * fix: commerce and enrollment apis return 403 when enrollment not allowed * fix: now both apis send the right message and http code when enrollment fails * fix: InvalidEnrollmentAtribute as final exception to catch and HTTP 400 returned * style: the message is displayed as a popup instead of creating a div at the end * fix: import not used removed for pylint checks * style: popup now use utility classes * refactor: use const instead of let for existing const * refactor: textContent const structure changed due check failed * refactor: SetTimeout settled as arrow function * feat: button incorporated to bring users enough time to read the message * refactor: ErrorStatuses defined at the top of the file to use it in conditionals * style: typo fixed Co-authored-by: Diana Olarte <dcoa@live.com> * refactor: double validation of redirectUrl eliminated and better styling of the message Co-authored-by: Diana Olarte <dcoa@live.com> * refactor: redirectUrl param eliminated in showmessage function, close button redirects to dashboard always * docs: remove unused redirectUrl param from JSDoc and explain hardcoded URL * style: endline added * feat: enrollmentNotAllowed exception added in views and the js * docs: comment added to especify exception * style: endline added * refactor: error statuses velidation changed to one single validation instead of two * refactor: function added to handle enrollment errors * feat: enrollmentNotAllowed exception added for API coherence and consistency * style: empty line added * style: pylint check line too long disabled --------- Co-authored-by: Diana Olarte <dcoa@live.com>
This commit is contained in:
@@ -9,17 +9,18 @@ from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from requests.exceptions import HTTPError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT
|
||||
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.entitlements.models import CourseEntitlement
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.models import CourseEnrollment, EnrollmentNotAllowed
|
||||
from common.djangoapps.util.json_request import JsonResponse
|
||||
from lms.djangoapps.courseware import courses
|
||||
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
|
||||
from openedx.core.djangoapps.embargo import api as embargo_api
|
||||
from openedx.core.djangoapps.enrollments.api import add_enrollment
|
||||
from openedx.core.djangoapps.enrollments.errors import InvalidEnrollmentAttribute
|
||||
from openedx.core.djangoapps.enrollments.views import EnrollmentCrossDomainSessionAuth
|
||||
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
|
||||
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
|
||||
@@ -149,7 +150,28 @@ class BasketsView(APIView):
|
||||
announcement=course_announcement
|
||||
)
|
||||
log.info(msg)
|
||||
self._enroll(course_key, user, default_enrollment_mode.slug)
|
||||
|
||||
try:
|
||||
self._enroll(course_key, user, default_enrollment_mode.slug)
|
||||
except InvalidEnrollmentAttribute as e:
|
||||
# Exception handling for InvalidEnrollmentAttribute
|
||||
return self._handle_enrollment_error(
|
||||
e,
|
||||
user,
|
||||
course_id,
|
||||
"Invalid enrollment attribute ",
|
||||
HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except EnrollmentNotAllowed as e:
|
||||
# Exception handling for EnrollmentNotAllowed
|
||||
return self._handle_enrollment_error(
|
||||
e,
|
||||
user,
|
||||
course_id,
|
||||
"Enrollment not allowed ",
|
||||
HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
mode = CourseMode.AUDIT if audit_mode else CourseMode.HONOR # lint-amnesty, pylint: disable=unused-variable
|
||||
self._handle_marketing_opt_in(request, course_key, user)
|
||||
return DetailResponse(msg)
|
||||
@@ -157,6 +179,24 @@ class BasketsView(APIView):
|
||||
msg = Messages.NO_DEFAULT_ENROLLMENT_MODE.format(course_id=course_id)
|
||||
return DetailResponse(msg, status=HTTP_406_NOT_ACCEPTABLE)
|
||||
|
||||
def _handle_enrollment_error(self, exception, user, course_id, log_message, status_code):
|
||||
"""
|
||||
Helper function to handle enrollment exceptions.
|
||||
|
||||
Args:
|
||||
exception (Exception): The exception raised.
|
||||
user (User): The user attempting to enroll.
|
||||
course_id (str): The course ID.
|
||||
log_message (str): The log message template.
|
||||
status_code (int): The HTTP status code to return.
|
||||
|
||||
Returns:
|
||||
DetailResponse: The response with the error message and status code.
|
||||
"""
|
||||
log.exception(log_message, str(exception))
|
||||
error_msg = f"{log_message.format(str(exception))} for user {user.username} in course {course_id}: {str(exception)}" # lint-amnesty, pylint: disable=line-too-long
|
||||
return DetailResponse(error_msg, status=status_code)
|
||||
|
||||
|
||||
class BasketOrderView(APIView):
|
||||
"""
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
'use strict';
|
||||
|
||||
define(['jquery', 'jquery.cookie'], function($) {
|
||||
const ErrorStatuses = {
|
||||
forbidden: 403,
|
||||
badRequest: 400
|
||||
};
|
||||
|
||||
var EnrollmentInterface = {
|
||||
|
||||
urls: {
|
||||
@@ -30,12 +35,14 @@
|
||||
context: this
|
||||
}).fail(function(jqXHR) {
|
||||
var responseData = JSON.parse(jqXHR.responseText);
|
||||
if (jqXHR.status === 403 && responseData.user_message_url) {
|
||||
// Check if we've been blocked from the course
|
||||
// because of country access rules.
|
||||
// If so, redirect to a page explaining to the user
|
||||
// why they were blocked.
|
||||
this.redirect(responseData.user_message_url);
|
||||
if (jqXHR.status === ErrorStatuses.forbidden) {
|
||||
if (responseData.user_message_url) {
|
||||
this.redirect(responseData.user_message_url);
|
||||
} else {
|
||||
this.showMessage(responseData);
|
||||
}
|
||||
} else if (jqXHR.status === ErrorStatuses.badRequest) {
|
||||
this.showMessage(responseData);
|
||||
} else {
|
||||
// Otherwise, redirect the user to the next page.
|
||||
if (redirectUrl) {
|
||||
@@ -52,7 +59,54 @@
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Show a message in the frontend.
|
||||
* @param {Object} message The message to display.
|
||||
*/
|
||||
showMessage: function(message) {
|
||||
const componentId = 'student-enrollment-feedback-error';
|
||||
const existing = document.getElementById(componentId);
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
// Using a fixed dashboard URL as the redirect destination since this is the most logical
|
||||
// place for users to go after encountering an enrollment error. The URL is hardcoded
|
||||
// because environment variables are not injected into the HTML/JavaScript context.
|
||||
const DASHBOARD_URL = '/dashboard';
|
||||
const textContent = (message && message.detail) ? message.detail : String(message);
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.setAttribute('id', componentId);
|
||||
messageDiv.setAttribute('class', 'fixed-top d-flex justify-content-center align-items-center');
|
||||
messageDiv.style.cssText = [
|
||||
'width:100vw',
|
||||
'height:100vh',
|
||||
'background:rgba(0,0,0,0.5)',
|
||||
'z-index:9999'
|
||||
].join(';');
|
||||
|
||||
const buttonText = typeof gettext === 'function' ? gettext('Close') : 'Close';
|
||||
|
||||
messageDiv.innerHTML = `
|
||||
<div class="page-banner w-75 has-actions">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<div class="row w-100">
|
||||
<div class="col d-flex align-items-center">
|
||||
<span class="icon icon-alert fa fa-warning me-2" aria-hidden="true"></span>
|
||||
<span class="message-content" style="min-width: 0; overflow-wrap: anywhere;">${textContent}</span>
|
||||
</div>
|
||||
<div class="nav-actions mt-3 flex-row-reverse d-none">
|
||||
<button type="button" class="action-primary" id="enrollment-redirect-btn">${buttonText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const actionContainer = messageDiv.querySelector('.nav-actions');
|
||||
actionContainer.classList.replace('d-none', 'd-flex');
|
||||
actionContainer.querySelector('button').addEventListener('click', () => this.redirect(DASHBOARD_URL) )
|
||||
document.body.appendChild(messageDiv);
|
||||
|
||||
},
|
||||
/**
|
||||
* Redirect to a URL. Mainly useful for mocking out in tests.
|
||||
* @param {string} url The URL to redirect to.
|
||||
@@ -65,3 +119,4 @@
|
||||
return EnrollmentInterface;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong-
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.student.auth import user_has_role
|
||||
from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed, User
|
||||
from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed, EnrollmentNotAllowed, User
|
||||
from common.djangoapps.student.roles import CourseStaffRole, GlobalStaff
|
||||
from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit
|
||||
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
|
||||
@@ -41,6 +41,7 @@ from openedx.core.djangoapps.enrollments.errors import (
|
||||
CourseEnrollmentError,
|
||||
CourseEnrollmentExistsError,
|
||||
CourseModeNotFoundError,
|
||||
InvalidEnrollmentAttribute,
|
||||
)
|
||||
from openedx.core.djangoapps.enrollments.forms import CourseEnrollmentsApiListForm
|
||||
from openedx.core.djangoapps.enrollments.paginators import CourseEnrollmentsApiListPagination
|
||||
@@ -869,6 +870,23 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
|
||||
|
||||
log.info("The user [%s] has already been enrolled in course run [%s].", username, course_id)
|
||||
return Response(response)
|
||||
|
||||
except InvalidEnrollmentAttribute as error:
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data={
|
||||
"message": str(error),
|
||||
"localizedMessage": str(error),
|
||||
}
|
||||
)
|
||||
except EnrollmentNotAllowed as error:
|
||||
return Response(
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
data={
|
||||
"message": str(error),
|
||||
"localizedMessage": str(error),
|
||||
}
|
||||
)
|
||||
except CourseModeNotFoundError as error:
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -901,6 +919,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
|
||||
).format(username=username, course_id=course_id)
|
||||
},
|
||||
)
|
||||
|
||||
except CourseUserGroup.DoesNotExist:
|
||||
log.exception("Missing cohort [%s] in course run [%s]", cohort_name, course_id)
|
||||
return Response(
|
||||
|
||||
Reference in New Issue
Block a user