Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6233b2d
feat: use extended profile model in the account settings
BryanttV Jul 31, 2025
6ff0412
feat: add bettter error handling in extended profile model function
BryanttV Aug 4, 2025
30d8cb7
docs: improve function docstring
BryanttV Aug 4, 2025
a898336
refactor: rename custom form functions to extended profile form
BryanttV Aug 5, 2025
5747b74
refactor: use atomic transaction and add comments
BryanttV Nov 12, 2025
ad36b40
refactor: rename parameter in extended profile form validation function
BryanttV Nov 12, 2025
62174ec
refactor: reduce levels of nastiness
BryanttV Nov 12, 2025
575ffbb
refactor: reduce level of nestiness
BryanttV Nov 12, 2025
bf54af1
fix: correct key name
BryanttV Nov 12, 2025
4343e62
chore: run make format
BryanttV Nov 13, 2025
74f103d
refactor: separate extended profile form validation from api layer
BryanttV Nov 21, 2025
966dc40
fix: handle None case for extended profile form validation
BryanttV Nov 21, 2025
62608df
refactor: lazy import get_auto_generated_username to avoid circular d…
BryanttV Nov 21, 2025
21a6bbb
refactor: rename variable for clarity in extended profile saving process
BryanttV Nov 21, 2025
666a442
test: add unit tests for extended profile form functions
BryanttV Nov 24, 2025
176cd20
test: add unit tests for get_extended_profile function
BryanttV Nov 24, 2025
9d2a40f
test: add unit tests for updating extended profile
BryanttV Nov 24, 2025
fe47add
test: add unit tests for extended profile in account api view
BryanttV Nov 24, 2025
e620c7f
test: add unit tests for get_extended_profile_model function
BryanttV Nov 24, 2025
287bc44
chore: add missing whitespace
BryanttV Nov 24, 2025
4fb774b
test: remove unnecessary unpack decorator
BryanttV Nov 24, 2025
350b9c1
refactor: update registration extension form handling to support new …
BryanttV Mar 3, 2026
cecbb7c
refactor: remove redundant logging import in registration form view
BryanttV Mar 3, 2026
d862b7c
test: update tests for get_extended_profile_model to use PROFILE_EXTE…
BryanttV Mar 4, 2026
53a9b59
fix: stop skipping null field values in extended profile fields update
BryanttV Mar 4, 2026
a0c077a
refactor: improve documentation in get_extended_profile_form function
BryanttV Mar 4, 2026
8379ee2
refactor: streamline error handling in get_extended_profile_form func…
BryanttV Mar 4, 2026
4c5750d
fix: ensure get_extended_profile_form returns None for invalid form i…
BryanttV Mar 4, 2026
aaa1327
refactor: enhance extended profile update logic to ensure atomic tran…
BryanttV Mar 4, 2026
0307656
refactor: clarify documentation in get_extended_profile function
BryanttV Mar 4, 2026
1636f4b
refactor: simplify error handling in get_extended_profile function
BryanttV Mar 4, 2026
cc2fed5
refactor: include exception context
BryanttV Mar 4, 2026
50f9c7c
refactor: remove test for None values in extended profile extraction
BryanttV Mar 4, 2026
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
3 changes: 2 additions & 1 deletion common/djangoapps/third_party_auth/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ def B(*args, **kwargs):
from openedx.core.djangoapps.user_authn import cookies as user_authn_cookies
from openedx.core.djangoapps.user_authn.toggles import is_auto_generated_username_enabled
from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect
from openedx.core.djangoapps.user_authn.views.utils import get_auto_generated_username
from common.djangoapps.third_party_auth.utils import (
get_associated_user_by_email_response,
get_user_from_email,
Expand Down Expand Up @@ -1010,6 +1009,8 @@ def get_username(strategy, details, backend, user=None, *args, **kwargs): # lin
slug_func = lambda val: val

if is_auto_generated_username_enabled() and details.get('username') is None:
# Lazy import to avoid circular dependency
from openedx.core.djangoapps.user_authn.views.utils import get_auto_generated_username
username = get_auto_generated_username(details)
else:
if email_as_username and details.get('email'):
Expand Down
11 changes: 9 additions & 2 deletions docs/concepts/extension_points.rst
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,16 @@ Here are the different integration points that python plugins can use:
- The course home page (the landing page for the course) includes a "Course Tools" section that provides links to "tools" associated with the course. Examples of course tool plugins included in the core are reviews, updates, and bookmarks. See |course_tools.py|_ to learn more.

This API may be changing soon with the new Courseware microfrontend implementation.
* - Custom registration form app (``REGISTRATION_EXTENSION_FORM`` Django setting in the LMS)
* - 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 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`_.
- 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
27 changes: 26 additions & 1 deletion lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2554,8 +2554,33 @@
# 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

REGISTRATION_EXTENSION_FORM = None
# 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
#
# 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.
PROFILE_EXTENSION_FORM = None

# Identifier included in the User Agent from Open edX mobile apps.
MOBILE_APP_USER_AGENT_REGEXES = [
Expand Down
64 changes: 57 additions & 7 deletions openedx/core/djangoapps/user_api/accounts/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
"""

import datetime
import logging
import re
from typing import Optional

from django import forms
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import ValidationError, validate_email
from django.db import transaction
from django.utils.translation import gettext as _
from django.utils.translation import override as override_language
from eventtracking import tracker
Expand Down Expand Up @@ -48,6 +52,8 @@
# pylint: disable=import-error
from edx_name_affirmation.name_change_validator import NameChangeValidator

logger = logging.getLogger(__name__)

# Public access point for this function.
visible_fields = _visible_fields

Expand Down Expand Up @@ -107,7 +113,7 @@ def get_account_settings(request, usernames=None, configuration=None, view=None)


@helpers.intercept_errors(errors.UserAPIInternalError, ignore_errors=[errors.UserAPIRequestError])
def update_account_settings(requesting_user, update, username=None):
def update_account_settings(requesting_user, update, username=None, extended_profile_form=None):
"""Update user account information.

Note:
Expand All @@ -120,6 +126,7 @@ def update_account_settings(requesting_user, update, username=None):
update (dict): The updated account field values.
username (str): Optional username specifying which account should be updated. If not specified,
`requesting_user.username` is assumed.
extended_profile_form (Optional[forms.Form]): Optional validated extended profile form instance.

Raises:
errors.UserNotFound: no user with username `username` exists (or `requesting_user.username` if
Expand Down Expand Up @@ -176,7 +183,7 @@ def update_account_settings(requesting_user, update, username=None):
_update_preferences_if_needed(update, requesting_user, user)
_notify_language_proficiencies_update_if_needed(update, user, user_profile, old_language_proficiencies)
_store_old_name_if_needed(old_name, user_profile, requesting_user)
_update_extended_profile_if_needed(update, user_profile)
_update_extended_profile_if_needed(update, user_profile, extended_profile_form)
_update_state_if_needed(update, user_profile)

except PreferenceValidationError as err:
Expand Down Expand Up @@ -346,17 +353,60 @@ def _notify_language_proficiencies_update_if_needed(data, user, user_profile, ol
)


def _update_extended_profile_if_needed(data, user_profile):
if 'extended_profile' in data:
def _update_extended_profile_if_needed(
data: dict, user_profile: UserProfile, extended_profile_form: Optional[forms.Form]
) -> None:
"""
Update the extended profile information if present in the data.

This function handles two types of extended profile updates:
1. Updates the user profile meta fields with extended_profile data
2. Saves the extended profile form data to the extended profile model if a validated form is provided

Args:
data (dict): Dictionary containing the update data, may include 'extended_profile' key
user_profile (UserProfile): The UserProfile instance to update
extended_profile_form (Optional[forms.Form]): The validated extended profile form
containing extended profile data, or None if no extended profile form is provided

Note:
If `extended_profile` is present in data, the function will:
- Extract `field_name` and `field_value` pairs from extended_profile list
- Update the `user_profile.meta` dictionary with new values
- Save the updated user_profile

If `extended_profile_form` is provided and valid, the function will:
- Save the form data to the extended profile model
- Associate the model instance with the user if it's a new instance
- Log any errors that occur during the save process
"""
if "extended_profile" in data:
meta = user_profile.get_meta()
new_extended_profile = data['extended_profile']
new_extended_profile = data["extended_profile"]
for field in new_extended_profile:
field_name = field['field_name']
new_value = field['field_value']
field_name = field["field_name"]
new_value = field["field_value"]
meta[field_name] = new_value
user_profile.set_meta(meta)
user_profile.save()

if extended_profile_form:
try:
with transaction.atomic():
# Use commit=False to create the model instance in memory without saving to DB yet.
# This allows us to set the user field before persisting, which is necessary because:
# 1. The form validates and creates the instance with form data
# 2. For new profiles, the user field isn't in the form data
# 3. We need to assign the user programmatically before the database save
# 4. If we called save() directly, it would fail with integrity errors for new profiles
extended_profile = extended_profile_form.save(commit=False)
if not hasattr(extended_profile, "user") or extended_profile.user is None:
extended_profile.user = user_profile.user
# Now persist the instance with the user field properly set
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors during the model save are caught and logged but not re-raised. Because the meta write (user_profile.save() above) already committed outside this savepoint, a failure here leaves meta updated but the model record stale — a silent partial save.

This appears intentional given test_update_extended_profile_form_save_error explicitly asserts the meta update still succeeds when the form save fails. If so, the docstring's Note should be updated to say so explicitly, e.g. "If the model save fails, the error is logged and the meta update is preserved (not rolled back)."

This also has the side effect that the meta values and the values in the model will get out of sync. Since we've now updated the code to read only from the model, this could end up hiding information we had intended to expose to the user. I don't necessarily want us to update the extended profile model on read but perhaps it wolud be good to merge the data from meta and the extended model on read (prioritizing the value in the model) to not lose data?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the best approach is to include both operations within the same transaction so there’s no risk of the values getting out of sync. Commit: aaa1327

extended_profile.save()
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error saving extended profile model: %s", e)


def _update_state_if_needed(data, user_profile):
# If the country was changed to something other than US, remove the state.
Expand Down
145 changes: 144 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@
Django forms for accounts
"""

import logging
from typing import Optional, Tuple

from django import forms
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils.translation import gettext as _

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,
)

logger = logging.getLogger(__name__)


class RetirementQueueDeletionForm(forms.Form):
Expand Down Expand Up @@ -35,3 +45,136 @@ def save(self, retirement):
raise ValidationError('Retirement is in the wrong state!')

handle_retirement_cancellation(retirement)


def extract_extended_profile_fields_data(extended_profile: Optional[list]) -> Tuple[dict, dict]:
"""
Extract extended profile fields data from extended_profile structure.

Args:
extended_profile (Optional[list]): List of field data dictionaries with keys
'field_name' and 'field_value'

Returns:
tuple: A tuple containing (extended_profile_fields_data, field_errors)
- extended_profile_fields_data (dict): Extracted custom fields data
- field_errors (dict): Dictionary of validation errors, if any
"""
field_errors = {}

if not isinstance(extended_profile, list):
field_errors["extended_profile"] = {
"developer_message": "extended_profile must be a list",
"user_message": _("Invalid extended profile format"),
}
return {}, field_errors

extended_profile_fields_data = {}

for field_data in extended_profile:
if not isinstance(field_data, dict):
logger.warning("Invalid field_data structure in extended_profile: %s", field_data)
continue

field_name = field_data.get("field_name")
field_value = field_data.get("field_value")

if not field_name:
logger.warning("Missing field_name in extended_profile field_data: %s", field_data)
continue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Entries where field_value is null are silently skipped. A client sending {"field_name": "title", "field_value": null} expecting to clear a field will get no error and no change. If clearing a field isn't supported, this should return a validation error. If it is supported (or intentionally deferred to the model form), that should be documented here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can see, the current behavior accepts any value, including null. Should we keep this behavior? I would say yes, and let the associated form handle the validation. Commit: 53a9b59


if field_value is not None:
extended_profile_fields_data[field_name] = field_value

return extended_profile_fields_data, field_errors


def get_extended_profile_form(extended_profile_fields_data: dict, user: User) -> Tuple[Optional[forms.Form], dict]:
"""
Get and validate an extended profile form instance.

Args:
extended_profile_fields_data (dict): Extended profile field data to populate the form
user (User): User instance to associate with the extended profile

Returns:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says "The validated form instance", but this function can return an invalid form when is_valid() fails — the invalid form is returned alongside the errors dict. The caller handles this correctly (view returns 400 when form_errors is non-empty), but the docstring is misleading. Consider saying "The form instance (may be invalid if field_errors is non-empty)" or returning None when the form is invalid.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that’s correct. I updated the docstring to make it clearer. Commit: a0c077a

tuple: A tuple containing (extended_profile_form, field_errors)
- extended_profile_form (Optional[forms.Form]): The validated form instance, or None if
no extended profile form is configured or creation fails
- field_errors (dict): Dictionary of validation errors, if any
"""
field_errors = {}

try:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code: get_extended_profile_model() catches all its own exceptions internally and always returns None on failure — it never raises. This except ImportError branch will never execute and can be removed.

# Simplify to just:
extended_profile_model = get_extended_profile_model()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, applied! Commit: 8379ee2

extended_profile_model = get_extended_profile_model()
except ImportError as e:
logger.warning("Extended profile model not available: %s", str(e))
return None, field_errors

kwargs = {}

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

try:
extended_profile_form = get_registration_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"] = {
"developer_message": f"Error creating custom form: {str(e)}",
"user_message": _("There was an error processing the extended profile information"),
}
return None, field_errors

if extended_profile_form is None:
return None, field_errors

if not extended_profile_form.is_valid():
logger.info("Extended profile form validation failed with errors: %s", extended_profile_form.errors)

for field_name, field_errors_list in extended_profile_form.errors.items():
first_error = field_errors_list[0] if field_errors_list else "Unknown error"
field_errors[field_name] = {
"developer_message": f"Error in extended profile field [{field_name}]: {first_error}",
"user_message": str(first_error),
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns the invalid form to the caller when validation fails (see above on the docstring issue). Since the caller uses form_errors to gate on whether to proceed, the invalid form object itself is harmless today — but it's a subtle footgun if any future caller doesn't check form_errors first.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree, changes applied! Commit: 4c5750d


return extended_profile_form, field_errors


def validate_and_get_extended_profile_form(
extended_profile_data: list, user: User
) -> Tuple[Optional[forms.Form], dict]:
"""
Validate and return an extended profile form instance.

This function orchestrates the extraction and validation of extended profile data.

Args:
extended_profile_data (list): The raw extended_profile data from the API request
user (User): The user instance for whom the extended profile is being validated

Returns:
tuple: A tuple containing (validated_form, field_errors)
- validated_form (Optional[forms.Form]): The validated form instance, or None if
validation fails or no extended profile is configured
- field_errors (dict): Dictionary of validation errors, if any
"""
extended_profile_fields_data, field_errors = extract_extended_profile_fields_data(extended_profile_data)

if field_errors:
return None, field_errors

if not extended_profile_fields_data:
return None, {}

extended_profile_form, form_errors = get_extended_profile_form(extended_profile_fields_data, user)

if form_errors:
field_errors.update(form_errors)

return extended_profile_form, field_errors
Loading
Loading