diff --git a/cms/envs/common.py b/cms/envs/common.py index a56af11c9c3d..39d5c7d13251 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2831,6 +2831,9 @@ # Redirect URL for inactive user. If not set, user will be redirected to /login after the login itself (loop) INACTIVE_USER_URL = f'http://{CMS_BASE}' +# String length for the configurable part of the auto-generated username +AUTO_GENERATED_USERNAME_RANDOM_STRING_LENGTH = 4 + ######################## BRAZE API SETTINGS ######################## EDX_BRAZE_API_KEY = None diff --git a/lms/envs/common.py b/lms/envs/common.py index d97cd31046b2..a342c45ffcc8 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3737,6 +3737,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # that match a regex in this list. Set to None to allow any email (default). REGISTRATION_EMAIL_PATTERNS_ALLOWED = None +# String length for the configurable part of the auto-generated username +AUTO_GENERATED_USERNAME_RANDOM_STRING_LENGTH = 4 + ########################## CERTIFICATE NAME ######################## CERT_NAME_SHORT = "Certificate" CERT_NAME_LONG = "Certificate of Achievement" diff --git a/openedx/core/djangoapps/user_authn/toggles.py b/openedx/core/djangoapps/user_authn/toggles.py index 38d9dbae4016..3ca3b75e9703 100644 --- a/openedx/core/djangoapps/user_authn/toggles.py +++ b/openedx/core/djangoapps/user_authn/toggles.py @@ -33,3 +33,21 @@ def should_redirect_to_authn_microfrontend(): return configuration_helpers.get_value( 'ENABLE_AUTHN_MICROFRONTEND', settings.FEATURES.get('ENABLE_AUTHN_MICROFRONTEND') ) + + +# .. toggle_name: ENABLE_AUTO_GENERATED_USERNAME +# .. toggle_implementation: DjangoSetting +# .. toggle_default: False +# .. toggle_description: Set to True to enable auto-generation of usernames. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2024-02-20 +# .. toggle_warning: Changing this setting may affect user authentication, account management and discussions experience. + + +def is_auto_generated_username_enabled(): + """ + Checks if auto-generated username should be enabled. + """ + return configuration_helpers.get_value( + 'ENABLE_AUTO_GENERATED_USERNAME', settings.FEATURES.get('ENABLE_AUTO_GENERATED_USERNAME') + ) diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index cbb0cb49f453..659c90b3d2c4 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -63,8 +63,12 @@ RegistrationFormFactory, get_registration_extension_form ) +from openedx.core.djangoapps.user_authn.views.utils import get_auto_generated_username from openedx.core.djangoapps.user_authn.tasks import check_pwned_password_and_send_track_event -from openedx.core.djangoapps.user_authn.toggles import is_require_third_party_auth_enabled +from openedx.core.djangoapps.user_authn.toggles import ( + is_require_third_party_auth_enabled, + is_auto_generated_username_enabled +) from common.djangoapps.student.helpers import ( AccountValidationError, authenticate_new_user, @@ -574,6 +578,9 @@ def post(self, request): data = request.POST.copy() self._handle_terms_of_service(data) + if is_auto_generated_username_enabled() and 'username' not in data: + data['username'] = get_auto_generated_username(data) + try: data = StudentRegistrationRequested.run_filter(form_data=data) except StudentRegistrationRequested.PreventRegistration as exc: 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 37aef7643a88..02d5a72074f5 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -65,6 +65,8 @@ password_validators_instruction_texts, password_validators_restrictions ) +ENABLE_AUTO_GENERATED_USERNAME = settings.FEATURES.copy() +ENABLE_AUTO_GENERATED_USERNAME['ENABLE_AUTO_GENERATED_USERNAME'] = True @ddt.ddt @@ -1861,6 +1863,117 @@ def test_rate_limiting_registration_view(self): assert response.status_code == 403 cache.clear() + @override_settings(FEATURES=ENABLE_AUTO_GENERATED_USERNAME) + def test_register_with_auto_generated_username(self): + """ + Test registration functionality with auto-generated username. + + This method tests the registration process when auto-generated username + feature is enabled. It creates a new user account, verifies that the user + account settings are correctly set, and checks if the user is successfully + logged in after registration. + """ + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "password": self.PASSWORD, + "honor_code": "true", + }) + self.assertHttpOK(response) + + user = User.objects.get(email=self.EMAIL) + request = RequestFactory().get('/url') + request.user = user + account_settings = get_account_settings(request)[0] + + assert self.EMAIL == account_settings["email"] + assert not account_settings["is_active"] + assert self.NAME == account_settings["name"] + + # Verify that we've been logged in + # by trying to access a page that requires authentication + response = self.client.get(reverse("dashboard")) + self.assertHttpOK(response) + + @override_settings(FEATURES=ENABLE_AUTO_GENERATED_USERNAME) + def test_register_with_empty_name(self): + """ + Test registration field validations when ENABLE_AUTO_GENERATED_USERNAME is enabled. + + Sends a POST request to the registration endpoint with empty name field. + Expects a 400 Bad Request response with the corresponding validation error message for the name field. + """ + response = self.client.post(self.url, { + "email": "bob@example.com", + "name": "", + "password": "password", + "honor_code": "true", + }) + assert response.status_code == 400 + response_json = json.loads(response.content.decode('utf-8')) + self.assertDictEqual( + response_json, + { + "name": [{"user_message": 'Your legal name must be a minimum of one character long'}], + "error_code": "validation-error" + } + ) + + @override_settings(FEATURES=ENABLE_AUTO_GENERATED_USERNAME) + @mock.patch('openedx.core.djangoapps.user_authn.views.utils._get_username_prefix') + @mock.patch('openedx.core.djangoapps.user_authn.views.utils.random.choices') + @mock.patch('openedx.core.djangoapps.user_authn.views.utils.datetime') + @mock.patch('openedx.core.djangoapps.user_authn.views.utils.get_auto_generated_username') + def test_register_autogenerated_duplicate_username(self, + mock_get_auto_generated_username, + mock_datetime, + mock_choices, + mock_get_username_prefix): + """ + Test registering a user with auto-generated username where a duplicate username conflict occurs. + + Mocks various utilities to control the auto-generated username process and verifies the response content + when a duplicate username conflict happens during user registration. + """ + mock_datetime.now.return_value.strftime.return_value = '24 03' + mock_choices.return_value = ['X', 'Y', 'Z', 'A'] # Fixed random string for testing + + mock_get_username_prefix.return_value = None + + current_year_month = f"{datetime.now().year % 100}{datetime.now().month:02d}_" + random_string = 'XYZA' + expected_username = current_year_month + random_string + mock_get_auto_generated_username.return_value = expected_username + + # Register the first user + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "password": self.PASSWORD, + "honor_code": "true", + }) + self.assertHttpOK(response) + # Try to create a second user with the same username + response = self.client.post(self.url, { + "email": "someone+else@example.com", + "name": "Someone Else", + "password": self.PASSWORD, + "honor_code": "true", + }) + + assert response.status_code == 409 + response_json = json.loads(response.content.decode('utf-8')) + response_json.pop('username_suggestions') + self.assertDictEqual( + response_json, + { + "username": [{ + "user_message": AUTHN_USERNAME_CONFLICT_MSG, + }], + "error_code": "duplicate-username" + } + ) + def _assert_fields_match(self, actual_field, expected_field): """ Assert that the actual field and the expected field values match. diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_utils.py b/openedx/core/djangoapps/user_authn/views/tests/test_utils.py new file mode 100644 index 000000000000..c931fe339901 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/views/tests/test_utils.py @@ -0,0 +1,77 @@ +""" +Tests for user utils functionality. +""" +from django.test import TestCase +from datetime import datetime +from openedx.core.djangoapps.user_authn.views.utils import get_auto_generated_username, _get_username_prefix +import ddt +from unittest.mock import patch + + +@ddt.ddt +class TestGenerateUsername(TestCase): + """ + Test case for the get_auto_generated_username function. + """ + + @ddt.data( + ({'first_name': 'John', 'last_name': 'Doe'}, "JD"), + ({'name': 'Jane Smith'}, "JS"), + ({'name': 'Jane'}, "J"), + ({'name': 'John Doe Smith'}, "JD") + ) + @ddt.unpack + def test_generate_username_from_data(self, data, expected_initials): + """ + Test get_auto_generated_username function. + """ + random_string = 'XYZA' + current_year_month = f"_{datetime.now().year % 100}{datetime.now().month:02d}_" + + with patch('openedx.core.djangoapps.user_authn.views.utils.random.choices') as mock_choices: + mock_choices.return_value = ['X', 'Y', 'Z', 'A'] + + username = get_auto_generated_username(data) + + expected_username = expected_initials + current_year_month + random_string + self.assertEqual(username, expected_username) + + @ddt.data( + ({'first_name': 'John', 'last_name': 'Doe'}, "JD"), + ({'name': 'Jane Smith'}, "JS"), + ({'name': 'Jane'}, "J"), + ({'name': 'John Doe Smith'}, "JD"), + ({'first_name': 'John Doe', 'last_name': 'Smith'}, "JD"), + ({}, None), + ({'first_name': '', 'last_name': ''}, None), + ({'name': ''}, None), + ({'first_name': '阿提亚', 'last_name': '阿提亚'}, "AT"), + ({'first_name': 'أحمد', 'last_name': 'محمد'}, "HM"), + ({'name': 'أحمد محمد'}, "HM"), + ) + @ddt.unpack + def test_get_username_prefix(self, data, expected_initials): + """ + Test _get_username_prefix function. + """ + username_prefix = _get_username_prefix(data) + self.assertEqual(username_prefix, expected_initials) + + @patch('openedx.core.djangoapps.user_authn.views.utils._get_username_prefix') + @patch('openedx.core.djangoapps.user_authn.views.utils.random.choices') + @patch('openedx.core.djangoapps.user_authn.views.utils.datetime') + def test_get_auto_generated_username_no_prefix(self, mock_datetime, mock_choices, mock_get_username_prefix): + """ + Test get_auto_generated_username function when no name data is provided. + """ + mock_datetime.now.return_value.strftime.return_value = f"{datetime.now().year % 100} {datetime.now().month:02d}" + mock_choices.return_value = ['X', 'Y', 'Z', 'A'] # Fixed random string for testing + + mock_get_username_prefix.return_value = None + + current_year_month = f"{datetime.now().year % 100}{datetime.now().month:02d}_" + random_string = 'XYZA' + expected_username = current_year_month + random_string + + username = get_auto_generated_username({}) + self.assertEqual(username, expected_username) diff --git a/openedx/core/djangoapps/user_authn/views/utils.py b/openedx/core/djangoapps/user_authn/views/utils.py index ac8e2c3950e4..c6107923a3f1 100644 --- a/openedx/core/djangoapps/user_authn/views/utils.py +++ b/openedx/core/djangoapps/user_authn/views/utils.py @@ -1,17 +1,24 @@ """ User Auth Views Utils """ +import logging +import re from django.conf import settings from django.contrib import messages from django.utils.translation import gettext as _ from ipware.ip import get_client_ip +from text_unidecode import unidecode from common.djangoapps import third_party_auth from common.djangoapps.third_party_auth import pipeline from common.djangoapps.third_party_auth.models import clean_username from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.geoinfo.api import country_code_from_ip +import random +import string +from datetime import datetime +log = logging.getLogger(__name__) API_V1 = 'v1' UUID4_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' ENTERPRISE_ENROLLMENT_URL_REGEX = fr'/enterprise/{UUID4_REGEX}/course/{settings.COURSE_KEY_REGEX}/enroll' @@ -108,3 +115,56 @@ def get_mfe_context(request, redirect_to, tpa_hint=None): 'countryCode': country_code, }) return context + + +def _get_username_prefix(data): + """ + Get the username prefix (name initials) based on the provided data. + + Args: + - data (dict): Registration payload. + + Returns: + - str: Name initials or None. + """ + username_regex_partial = settings.USERNAME_REGEX_PARTIAL + full_name = '' + if data.get('first_name', '').strip() and data.get('last_name', '').strip(): + full_name = f"{unidecode(data.get('first_name', ''))} {unidecode(data.get('last_name', ''))}" + elif data.get('name', '').strip(): + full_name = unidecode(data['name']) + + if full_name.strip(): + full_name = re.findall(username_regex_partial, full_name)[0] + name_initials = "".join([name_part[0] for name_part in full_name.split()[:2]]) + return name_initials.upper() if name_initials else None + + return None + + +def get_auto_generated_username(data): + """ + Generate username based on learner's name initials, current date and configurable random string. + + This function creates a username in the format __ + + The length of random string is determined by AUTO_GENERATED_USERNAME_RANDOM_STRING_LENGTH setting. + + Args: + - data (dict): Registration payload. + + Returns: + - str: username. + """ + current_year, current_month = datetime.now().strftime('%y %m').split() + + random_string = ''.join(random.choices( + string.ascii_uppercase + string.digits, + k=settings.AUTO_GENERATED_USERNAME_RANDOM_STRING_LENGTH)) + + username_prefix = _get_username_prefix(data) + username_suffix = f"{current_year}{current_month}_{random_string}" + + # We generate the username regardless of whether the name is empty or invalid. We do this + # because the name validations occur later, ensuring that users cannot create an account without a valid name. + return f"{username_prefix}_{username_suffix}" if username_prefix else username_suffix