add username suggestions functionality (#27387)
Added username suggestions functionality upon username already exists validation error for both registration and registration validation endpoints. VAN-52
This commit is contained in:
@@ -5,11 +5,15 @@ Utility functions used during user authentication.
|
||||
import random
|
||||
import string
|
||||
from urllib.parse import urlparse # pylint: disable=import-error
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import http
|
||||
from oauth2_provider.models import Application
|
||||
|
||||
from common.djangoapps.student.models import username_exists_or_retired
|
||||
from openedx.core.djangoapps.user_api import accounts
|
||||
|
||||
|
||||
def is_safe_login_or_logout_redirect(redirect_to, request_host, dot_client_id, require_https):
|
||||
"""
|
||||
@@ -66,3 +70,18 @@ def is_registration_api_v1(request):
|
||||
:return: Bool
|
||||
"""
|
||||
return 'v1' in request.get_full_path() and 'register' not in request.get_full_path()
|
||||
|
||||
|
||||
def generate_username_suggestions(username):
|
||||
""" Generate available username suggestions """
|
||||
min_length = accounts.USERNAME_MIN_LENGTH
|
||||
max_length = accounts.USERNAME_MAX_LENGTH
|
||||
short_username = username[:max_length - min_length] if max_length is not None else username
|
||||
|
||||
username_suggestions = []
|
||||
while len(username_suggestions) < 3:
|
||||
username = f'{short_username}_{uuid4().hex[:min_length]}'
|
||||
if not username_exists_or_retired(username):
|
||||
username_suggestions.append(username)
|
||||
|
||||
return username_suggestions
|
||||
|
||||
@@ -51,7 +51,9 @@ from openedx.core.djangoapps.user_api.accounts.api import (
|
||||
)
|
||||
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
|
||||
from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies
|
||||
from openedx.core.djangoapps.user_authn.utils import generate_password, is_registration_api_v1
|
||||
from openedx.core.djangoapps.user_authn.utils import (
|
||||
generate_password, generate_username_suggestions, is_registration_api_v1
|
||||
)
|
||||
from openedx.core.djangoapps.user_authn.views.registration_form import (
|
||||
AccountCreationForm,
|
||||
RegistrationFormFactory,
|
||||
@@ -546,11 +548,12 @@ class RegistrationView(APIView):
|
||||
error_code = 'duplicate'
|
||||
if email is not None and email_exists_or_retired(email):
|
||||
error_code += '-email'
|
||||
errors["email"] = [{"user_message": accounts_settings.EMAIL_CONFLICT_MSG.format(email_address=email)}]
|
||||
errors['email'] = [{'user_message': accounts_settings.EMAIL_CONFLICT_MSG.format(email_address=email)}]
|
||||
|
||||
if username is not None and username_exists_or_retired(username):
|
||||
error_code += '-username'
|
||||
errors["username"] = [{"user_message": accounts_settings.USERNAME_CONFLICT_MSG.format(username=username)}]
|
||||
errors['username'] = [{'user_message': accounts_settings.USERNAME_CONFLICT_MSG.format(username=username)}]
|
||||
errors['username_suggestions'] = generate_username_suggestions(username)
|
||||
|
||||
if errors:
|
||||
return self._create_response(request, errors, status_code=409, error_code=error_code)
|
||||
@@ -714,6 +717,7 @@ class RegistrationValidationView(APIView):
|
||||
|
||||
# This end-point is available to anonymous users, so no authentication is needed.
|
||||
authentication_classes = []
|
||||
username_suggestions = []
|
||||
|
||||
def name_handler(self, request):
|
||||
name = request.data.get('name')
|
||||
@@ -724,6 +728,8 @@ class RegistrationValidationView(APIView):
|
||||
username = request.data.get('username')
|
||||
invalid_username_error = get_username_validation_error(username)
|
||||
username_exists_error = get_username_existence_validation_error(username)
|
||||
if username_exists_error:
|
||||
self.username_suggestions = generate_username_suggestions(username)
|
||||
# We prefer seeing for invalidity first.
|
||||
# Some invalid usernames (like for superusers) may exist.
|
||||
return invalid_username_error or username_exists_error
|
||||
@@ -794,9 +800,8 @@ class RegistrationValidationView(APIView):
|
||||
form_field_key: handler(self, request)
|
||||
})
|
||||
|
||||
field_name = request.data.get('fieldName') # adding field name for authn MFE use case
|
||||
if field_name:
|
||||
validation_decisions.update({
|
||||
'fieldName': field_name
|
||||
})
|
||||
return Response({"validation_decisions": validation_decisions})
|
||||
response_dict = {"validation_decisions": validation_decisions}
|
||||
if self.username_suggestions:
|
||||
response_dict['username_suggestions'] = self.username_suggestions
|
||||
|
||||
return Response(response_dict)
|
||||
|
||||
@@ -180,6 +180,8 @@ class RegistrationViewValidationErrorTest(ThirdPartyAuthTestMixin, UserAPITestCa
|
||||
assert response.status_code == 409
|
||||
|
||||
response_json = json.loads(response.content.decode('utf-8'))
|
||||
username_suggestions = response_json.pop('username_suggestions')
|
||||
assert len(username_suggestions) == 3
|
||||
self.assertDictEqual(
|
||||
response_json,
|
||||
{
|
||||
@@ -293,6 +295,8 @@ class RegistrationViewValidationErrorTest(ThirdPartyAuthTestMixin, UserAPITestCa
|
||||
|
||||
assert response.status_code == 409
|
||||
response_json = json.loads(response.content.decode('utf-8'))
|
||||
username_suggestions = response_json.pop('username_suggestions')
|
||||
assert len(username_suggestions) == 3
|
||||
self.assertDictEqual(
|
||||
response_json,
|
||||
{
|
||||
@@ -330,6 +334,8 @@ class RegistrationViewValidationErrorTest(ThirdPartyAuthTestMixin, UserAPITestCa
|
||||
|
||||
assert response.status_code == 409
|
||||
response_json = json.loads(response.content.decode('utf-8'))
|
||||
username_suggestions = response_json.pop('username_suggestions')
|
||||
assert len(username_suggestions) == 3
|
||||
self.assertDictEqual(
|
||||
response_json,
|
||||
{
|
||||
@@ -1488,6 +1494,8 @@ class RegistrationViewTestV1(ThirdPartyAuthTestMixin, UserAPITestCase):
|
||||
|
||||
assert response.status_code == 409
|
||||
response_json = json.loads(response.content.decode('utf-8'))
|
||||
username_suggestions = response_json.pop('username_suggestions')
|
||||
assert len(username_suggestions) == 3
|
||||
self.assertDictEqual(
|
||||
response_json,
|
||||
{
|
||||
@@ -1525,6 +1533,8 @@ class RegistrationViewTestV1(ThirdPartyAuthTestMixin, UserAPITestCase):
|
||||
|
||||
assert response.status_code == 409
|
||||
response_json = json.loads(response.content.decode('utf-8'))
|
||||
username_suggestions = response_json.pop('username_suggestions')
|
||||
assert len(username_suggestions) == 3
|
||||
self.assertDictEqual(
|
||||
response_json,
|
||||
{
|
||||
@@ -2175,15 +2185,24 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase):
|
||||
super().setUp()
|
||||
cache.clear()
|
||||
|
||||
def get_validation_decision(self, data):
|
||||
response = self.client.post(self.path, data)
|
||||
def get_validation_response(self, data):
|
||||
return self.client.post(self.path, data)
|
||||
|
||||
def get_validation_decision(self, response):
|
||||
return response.data.get('validation_decisions', {})
|
||||
|
||||
def assertValidationDecision(self, data, decision):
|
||||
assert self.get_validation_decision(data) == decision
|
||||
def get_username_suggestions(self, response):
|
||||
return response.data.get('username_suggestions', [])
|
||||
|
||||
def assertValidationDecision(self, data, decision, validate_suggestions=False):
|
||||
response = self.get_validation_response(data)
|
||||
assert self.get_validation_decision(response) == decision
|
||||
if validate_suggestions:
|
||||
assert len(self.get_username_suggestions(response)) == 3
|
||||
|
||||
def assertNotValidationDecision(self, data, decision):
|
||||
assert self.get_validation_decision(data) != decision
|
||||
response = self.get_validation_response(data)
|
||||
assert self.get_validation_decision(response) != decision
|
||||
|
||||
def test_no_decision_for_empty_request(self):
|
||||
self.assertValidationDecision(
|
||||
@@ -2233,13 +2252,13 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase):
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
['username', 'username@email.com'], # No conflict
|
||||
['user', 'username@email.com'], # Username conflict
|
||||
['username', 'user@email.com'], # Email conflict
|
||||
['user', 'user@email.com'] # Both conflict
|
||||
['username', 'username@email.com', False], # No conflict
|
||||
['user', 'username@email.com', True], # Username conflict
|
||||
['username', 'user@email.com', False], # Email conflict
|
||||
['user', 'user@email.com', True] # Both conflict
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_existence_conflict(self, username, email):
|
||||
def test_existence_conflict(self, username, email, validate_suggestions):
|
||||
"""
|
||||
Test if username '{0}' and email '{1}' have conflicts with
|
||||
username 'user' and email 'user@email.com'.
|
||||
@@ -2259,7 +2278,8 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase):
|
||||
"email": EMAIL_CONFLICT_MSG.format(
|
||||
email_address=user.email
|
||||
) if email == user.email else ''
|
||||
}
|
||||
},
|
||||
validate_suggestions
|
||||
)
|
||||
|
||||
@ddt.data('', ('e' * EMAIL_MAX_LENGTH) + '@email.com')
|
||||
|
||||
Reference in New Issue
Block a user