feat: autogenerate username on registration (#34562)
* feat: autogenerate username on registration --------- Co-authored-by: Attiya Ishaque <atiya.ishaq@arbisoft.com> Co-authored-by: Blue <ahtesham-quraish@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
98dd951225
commit
2ce25b3eb6
@@ -2831,6 +2831,9 @@ INACTIVE_USER_LOGIN = True
|
||||
# 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
|
||||
|
||||
@@ -3737,6 +3737,9 @@ REGISTRATION_FIELD_ORDER = [
|
||||
# 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"
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -63,8 +63,12 @@ from openedx.core.djangoapps.user_authn.views.registration_form import (
|
||||
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 @@ class RegistrationView(APIView):
|
||||
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:
|
||||
|
||||
@@ -65,6 +65,8 @@ from common.djangoapps.util.password_policy_validators import (
|
||||
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 @@ class RegistrationViewTestV1(
|
||||
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.
|
||||
|
||||
77
openedx/core/djangoapps/user_authn/views/tests/test_utils.py
Normal file
77
openedx/core/djangoapps/user_authn/views/tests/test_utils.py
Normal file
@@ -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)
|
||||
@@ -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 <name_initials>_<YYMM>_<configurable_random_string>
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user