Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions docs/concepts/extension_points.rst
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,6 @@ Here are the different integration points that python plugins can use:
* - Custom profile extension form app (``PROFILE_EXTENSION_FORM`` Django setting in the LMS)
- Trial, Stable
- By default, the registration page for each instance of Open edX has fields that ask for information such as a user’s name, country, and highest level of education completed. You can add custom fields to the registration page and user profile for your own Open edX instance. These fields can be different types, including text entry fields and drop-down lists. See `Adding Custom Fields to the Registration Page`_.

**Important Migration Note:**

- ``REGISTRATION_EXTENSION_FORM`` (deprecated) continues to work with old behavior: custom fields only for registration, data stored in UserProfile.meta
- ``PROFILE_EXTENSION_FORM`` (new) enables new capabilities: custom fields in registration and account settings, data stored in dedicated model

Sites using the deprecated setting will maintain backward compatibility. To get the new capabilities, migrate to ``PROFILE_EXTENSION_FORM``.
* - Learning Context (``openedx.learning_context``)
- Trial, Limited
- A "Learning Context" is a course, a library, a program, a blog, an external site, or some other collection of content where learning happens. If you are trying to build a totally new learning experience that's not a type of course, you may need to implement a new learning context. Learning contexts are a new abstraction and are only supported in the nascent openedx_content-based XBlock runtime. Since existing courses use modulestore instead of openedx_content, they are not yet implemented as learning contexts. However, openedx_content-based content libraries are. See |learning_context.py|_ to learn more.
Expand Down
45 changes: 13 additions & 32 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2620,40 +2620,21 @@
FINANCIAL_ASSISTANCE_MIN_LENGTH = 1250
FINANCIAL_ASSISTANCE_MAX_LENGTH = 2500

#### Registration form extension. ####
# Only used if combined login/registration is enabled.
# This can be used to add fields to the registration page.
# It must be a path to a valid form, in dot-separated syntax.
# IE: custom_form_app.forms.RegistrationExtensionForm
# Note: If you want to use a model to store the results of the form, you will
# need to add the model's app to the ADDL_INSTALLED_APPS array in your
# lms.yml file.
#
# REGISTRATION_EXTENSION_FORM is deprecated but will continue to work for backward compatibility.
# Sites using this setting will maintain the old behavior:
# - Data is stored in UserProfile.meta JSON field
# - No ability to update extended fields after registration via account settings API
#
# To get new capabilities (model-based storage), migrate to PROFILE_EXTENSION_FORM.
REGISTRATION_EXTENSION_FORM = None # DEPRECATED: Use PROFILE_EXTENSION_FORM instead
##### User Profile Extension #####

# PROFILE_EXTENSION_FORM is a Django ModelForm class used for extending user profiles
# beyond the default fields. This setting enables new capabilities for profile management:
# - Data is stored in a dedicated model (not just UserProfile.meta)
# - Users can update their extended profile fields via the account settings API
#
# This setting supersedes REGISTRATION_EXTENSION_FORM and provides more accurate naming
# for profile extension functionality.
#
# Example: PROFILE_EXTENSION_FORM = 'myapp.forms.ExtendedProfileForm'
#
# The custom form's model should have:
# - A OneToOneField to User (typically named 'user')
# - Additional fields for extended profile data
# PROFILE_EXTENSION_FORM is a Django ModelForm used to add extended user
# fields to the platform. This setting integrates these fields into both
# the registration form and the account settings api.

# Usage:
# - Set this to the dot-separated path of a valid ModelForm class.
# Example: PROFILE_EXTENSION_FORM = 'my_custom_app.forms.ExtendedProfileForm'
#
# MIGRATION NOTE: If you're currently using REGISTRATION_EXTENSION_FORM (deprecated),
# your custom fields will continue working as before (data in meta field).
# To get the new capabilities, migrate to PROFILE_EXTENSION_FORM.
# Requirements:
# 1. Linked Model: The form must be based on a model with a OneToOneField to
# the User (typically named 'user').
# 2. Installed Apps: Ensure the app containing your model is included in
# the ADDL_INSTALLED_APPS array.
PROFILE_EXTENSION_FORM = None

# Identifier included in the User Agent from Open edX mobile apps.
Expand Down
18 changes: 9 additions & 9 deletions openedx/core/djangoapps/user_api/accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
from common.djangoapps.student.models import User
from openedx.core.djangoapps.user_api.accounts.utils import handle_retirement_cancellation
from openedx.core.djangoapps.user_authn.views.registration_form import (
get_extended_profile_model,
get_registration_extension_form,
get_profile_extension_form,
get_profile_extension_model,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -108,22 +108,22 @@ def get_extended_profile_form(
- field_errors (dict): Dictionary of validation errors, if any
"""
field_errors, kwargs = {}, {}
extended_profile_model = get_extended_profile_model()
profile_extension_model = get_profile_extension_model()

try:
kwargs["instance"] = extended_profile_model.objects.get(user=user)
kwargs["instance"] = profile_extension_model.objects.get(user=user)
except AttributeError:
logger.info("No extended profile model configured")
logger.info("No profile extension model configured")
except ObjectDoesNotExist:
logger.info("No existing extended profile found for user %s, creating new instance", user.username)
logger.info("No existing profile extension found for user %s, creating new instance", user.username)

try:
extended_profile_form = get_registration_extension_form(data=extended_profile_fields_data, **kwargs)
extended_profile_form = get_profile_extension_form(data=extended_profile_fields_data, **kwargs)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Unexpected error creating custom form for user %s: %s", user.username, str(e))
field_errors["extended_profile"] = {
field_errors["profile_extension"] = {
"developer_message": f"Error creating custom form: {str(e)}",
"user_message": _("There was an error processing the extended profile information"),
"user_message": _("There was an error processing the profile extension information"),
}
return None, field_errors

Expand Down
50 changes: 27 additions & 23 deletions openedx/core/djangoapps/user_api/accounts/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from openedx.core.djangoapps.user_authn.views.registration_form import (
contains_html,
contains_url,
get_extended_profile_model,
get_profile_extension_model,
)
from openedx.features.name_affirmation_api.utils import get_name_affirmation_service

Expand Down Expand Up @@ -577,42 +577,46 @@ def get_extended_profile(user_profile: UserProfile) -> list[dict[str, str]]:

This function extracts custom profile fields that extend beyond the standard
UserProfile model. It prefers data from a custom extended profile model
(when configured), and only uses the `user_profile.meta` JSON field when
no such model is configured. The returned data is filtered to include only
fields specified in the `extended_profile_fields` site configuration.
(when configured), falling back to the `user_profile.meta` JSON field for
any field not present in the model (or when the user has no model record yet).

The function supports two data sources:
The returned data is filtered to include only fields specified in the
`extended_profile_fields` site configuration.

The function supports two data sources (applied per field):
1. Custom model: If the `PROFILE_EXTENSION_FORM` setting points to a form with a
`Meta.model`, data is retrieved from that model using `model_to_dict()`. If a
model is configured but the user does not yet have a corresponding record,
this function returns an empty mapping for extended profile fields (it does
not fall back to `user_profile.meta` in that case).
2. Fallback: JSON data stored in `UserProfile.meta` field, used only when no
custom extended profile model is configured.
`Meta.model`, data is retrieved from that model using `model_to_dict()`.
Fields not present in the model, or fields when the user has no model record,
fall back to `user_profile.meta`.
2. Fallback: JSON data stored in `UserProfile.meta` field, used when no custom
extended profile model is configured or when the model lacks a given field.

Args:
user_profile (UserProfile): The user profile instance to get extended fields from.

Returns:
list[dict[str, str]]: A list of dictionaries, each containing:
- field_name: The name of the extended profile field
- field_value: The value of the field (converted to string)
- field_value: The value of the field
"""

def get_extended_profile_data():
extended_profile_model = get_extended_profile_model()

if extended_profile_model:
try:
profile_obj = extended_profile_model.objects.get(user=user_profile.user)
return model_to_dict(profile_obj)
except extended_profile_model.DoesNotExist:
return {}

try:
return json.loads(user_profile.meta or "{}")
meta_data = json.loads(user_profile.meta or "{}")
except (ValueError, TypeError, AttributeError):
return {}
meta_data = {}

profile_extension_model = get_profile_extension_model()
if profile_extension_model:
try:
profile_obj = profile_extension_model.objects.get(user=user_profile.user)
model_data = model_to_dict(profile_obj)
# Model fields take precedence. Meta fills in any field absent from the model.
return {**meta_data, **model_data}
except ObjectDoesNotExist:
pass

return meta_data

data = get_extended_profile_data()
field_names = configuration_helpers.get_value("extended_profile_fields", [])
Expand Down
30 changes: 15 additions & 15 deletions openedx/core/djangoapps/user_api/accounts/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def setUp(self):
super().setUp()
self.user = UserFactory.create()

@patch("openedx.core.djangoapps.user_api.accounts.forms.get_extended_profile_model")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_model")
def test_get_extended_profile_form_no_model_configured(self, mock_get_model: Mock):
"""
Test when no extended profile model is configured
Expand All @@ -126,7 +126,7 @@ def test_get_extended_profile_form_no_model_configured(self, mock_get_model: Moc
self.assertIsNone(form) # noqa: PT009
self.assertEqual(errors, {}) # noqa: PT009

@patch("openedx.core.djangoapps.user_api.accounts.forms.get_extended_profile_model")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_model")
def test_get_extended_profile_form_model_has_no_objects(self, mock_get_model: Mock):
"""
Test when model doesn't have objects attribute (AttributeError)
Expand All @@ -140,8 +140,8 @@ def test_get_extended_profile_form_model_has_no_objects(self, mock_get_model: Mo
self.assertIsNone(form) # noqa: PT009
self.assertEqual(errors, {}) # noqa: PT009

@patch("openedx.core.djangoapps.user_api.accounts.forms.get_registration_extension_form")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_extended_profile_model")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_form")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_model")
def test_get_extended_profile_form_with_existing_instance(self, mock_get_model: Mock, mock_get_form: Mock):
"""
Test form creation with an existing profile instance
Expand All @@ -162,8 +162,8 @@ def test_get_extended_profile_form_with_existing_instance(self, mock_get_model:
mock_model.objects.get.assert_called_once_with(user=self.user)
mock_get_form.assert_called_once_with(data=extended_profile_fields_data, instance=mock_instance)

@patch("openedx.core.djangoapps.user_api.accounts.forms.get_registration_extension_form")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_extended_profile_model")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_form")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_model")
def test_get_extended_profile_form_without_existing_instance(self, mock_get_model: Mock, mock_get_form: Mock):
"""
Test form creation for a new profile (no existing instance)
Expand All @@ -184,8 +184,8 @@ def test_get_extended_profile_form_without_existing_instance(self, mock_get_mode
mock_model.objects.get.assert_called_once_with(user=self.user)
mock_get_form.assert_called_once_with(data=extended_profile_fields_data)

@patch("openedx.core.djangoapps.user_api.accounts.forms.get_registration_extension_form")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_extended_profile_model")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_form")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_model")
def test_get_extended_profile_form_validation_errors(self, mock_get_model: Mock, mock_get_form: Mock):
"""
Test when form validation fails
Expand All @@ -205,34 +205,34 @@ def test_get_extended_profile_form_validation_errors(self, mock_get_model: Mock,
self.assertEqual(errors["department"]["user_message"], "This field is required") # noqa: PT009
self.assertEqual(errors["title"]["user_message"], "Invalid value") # noqa: PT009

@patch("openedx.core.djangoapps.user_api.accounts.forms.get_registration_extension_form")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_form")
def test_get_extended_profile_form_returns_none(self, mock_get_form: Mock):
"""
Test when get_registration_extension_form returns None
Test when get_profile_extension_form returns None
"""
mock_get_form.return_value = None
extended_profile_fields_data = {"department": "Engineering"}

with patch("openedx.core.djangoapps.user_api.accounts.forms.get_extended_profile_model"):
with patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_model"):
form, errors = get_extended_profile_form(extended_profile_fields_data, self.user)

self.assertIsNone(form) # noqa: PT009
self.assertEqual(errors, {}) # noqa: PT009

@patch("openedx.core.djangoapps.user_api.accounts.forms.get_registration_extension_form")
@patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_form")
def test_get_extended_profile_form_exception_during_creation(self, mock_get_form: Mock):
"""
Test when an unexpected exception occurs during form creation
"""
mock_get_form.side_effect = Exception("Unexpected error")
extended_profile_fields_data = {"department": "Engineering"}

with patch("openedx.core.djangoapps.user_api.accounts.forms.get_extended_profile_model"):
with patch("openedx.core.djangoapps.user_api.accounts.forms.get_profile_extension_model"):
form, errors = get_extended_profile_form(extended_profile_fields_data, self.user)

self.assertIsNone(form) # noqa: PT009
self.assertIn("extended_profile", errors) # noqa: PT009
self.assertIn("Error creating custom form", errors["extended_profile"]["developer_message"]) # noqa: PT009
self.assertIn("profile_extension", errors) # noqa: PT009
self.assertIn("Error creating custom form", errors["profile_extension"]["developer_message"]) # noqa: PT009


class TestValidateAndGetExtendedProfileForm(TestCase):
Expand Down
Loading
Loading