Skip to content

Commit

Permalink
feat: autogenerate username on registration (openedx#34562)
Browse files Browse the repository at this point in the history
* feat: autogenerate username on registration

---------

Co-authored-by: Attiya Ishaque <[email protected]>
Co-authored-by: Blue <[email protected]>
  • Loading branch information
3 people authored May 2, 2024
1 parent 98dd951 commit 2ce25b3
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 1 deletion.
3 changes: 3 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions openedx/core/djangoapps/user_authn/toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
)
9 changes: 8 additions & 1 deletion openedx/core/djangoapps/user_authn/views/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
113 changes: 113 additions & 0 deletions openedx/core/djangoapps/user_authn/views/tests/test_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": "[email protected]",
"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": "[email protected]",
"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.
Expand Down
77 changes: 77 additions & 0 deletions openedx/core/djangoapps/user_authn/views/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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)
60 changes: 60 additions & 0 deletions openedx/core/djangoapps/user_authn/views/utils.py
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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

0 comments on commit 2ce25b3

Please sign in to comment.