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:
Waheed Ahmed
2021-04-22 14:08:40 +05:00
committed by GitHub
parent 55b1375c7c
commit 5aa8245133
3 changed files with 64 additions and 20 deletions

View File

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

View File

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

View File

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