diff --git a/openedx/core/djangoapps/user_authn/utils.py b/openedx/core/djangoapps/user_authn/utils.py index 10f3d40033..f00c3f4cba 100644 --- a/openedx/core/djangoapps/user_authn/utils.py +++ b/openedx/core/djangoapps/user_authn/utils.py @@ -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 diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index c97f973af8..74adfef418 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -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) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 4280b7d41c..f671c1f825 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -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')