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
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 @@ -102,7 +102,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 . import provider

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 @@ -2630,8 +2630,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
98 changes: 87 additions & 11 deletions openedx/core/djangoapps/user_api/accounts/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
"""

import datetime
import logging
import re
from zoneinfo import ZoneInfo

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 DatabaseError, IntegrityError, 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 @@ -41,13 +44,16 @@
from openedx.core.lib.api.view_utils import add_serializer_errors
from openedx.features.name_affirmation_api.utils import is_name_affirmation_installed

from .forms import validate_and_get_extended_profile_form
from .serializers import AccountLegacyProfileSerializer, AccountUserSerializer, UserReadOnlySerializer, _visible_fields

name_affirmation_installed = is_name_affirmation_installed()
if name_affirmation_installed:
# 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 @@ -165,6 +171,12 @@ def update_account_settings(requesting_user, update, username=None):
old_name = _validate_name_change(user_profile, update, field_errors)
old_language_proficiencies = _get_old_language_proficiencies_if_updating(user_profile, update)

extended_profile_data = update.get("extended_profile") if "extended_profile" in update else None
extended_profile_form = None
if extended_profile_data is not None:
extended_profile_form, ext_profile_errors = validate_and_get_extended_profile_form(extended_profile_data, user)
field_errors.update(ext_profile_errors)

if field_errors:
raise errors.AccountValidationError(field_errors)

Expand All @@ -176,7 +188,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 @@ -352,17 +364,81 @@ 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:
meta = user_profile.get_meta()
new_extended_profile = data['extended_profile']
for field in new_extended_profile:
field_name = field['field_name']
new_value = field['field_value']
meta[field_name] = new_value
user_profile.set_meta(meta)
user_profile.save()
def _update_extended_profile_if_needed(
data: dict, user_profile: UserProfile, extended_profile_form: forms.Form | None
) -> 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 (forms.Form | None): 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 and save the 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

Both the meta update and the extended profile model save (when present) are performed
within a single database transaction. If either operation fails, the transaction is
rolled back so that no partial updates are persisted. The error is logged and an
AccountUpdateError is raised to the caller.
"""
has_extended_profile_data = "extended_profile" in data
has_extended_profile_form = extended_profile_form is not None

if not has_extended_profile_data and not has_extended_profile_form:
return

try:
with transaction.atomic():
if has_extended_profile_data:
meta = user_profile.get_meta()
new_extended_profile = data["extended_profile"]
for field in new_extended_profile:
field_name = field["field_name"]
new_value = field["field_value"]
meta[field_name] = new_value
user_profile.set_meta(meta)
user_profile.save()

if has_extended_profile_form:
# 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
extended_profile.save()
except ValidationError as exc:
raise AccountUpdateError(
developer_message=f"Extended profile validation failed: {str(exc)}",
user_message=_("The extended profile information could not be saved due to validation errors."),
) from exc
except IntegrityError as exc:
raise AccountUpdateError(
developer_message=f"Extended profile integrity error: {str(exc)}",
user_message=_("The extended profile information could not be saved. Please check for duplicate values."),
) from exc
except DatabaseError as exc:
raise AccountUpdateError(
developer_message=f"Database error saving extended profile: {str(exc)}",
user_message=_("The extended profile information could not be saved due to a system error."),
) from exc

def _update_state_if_needed(data, user_profile):
# If the country was changed to something other than US, remove the state.
Expand Down
144 changes: 143 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,20 @@
Django forms for accounts
"""

import logging

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 +44,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: list[dict] | None) -> tuple[dict, dict]:
"""
Extract extended profile fields data from extended_profile structure.

Args:
extended_profile (list[dict] | None): 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

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[forms.Form | None, 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:
tuple: A tuple containing (extended_profile_form, field_errors)
- extended_profile_form (forms.Form | None): The validated form instance,
or None if no extended profile form is configured, creation fails,
or form validation fails.
- field_errors (dict): Dictionary of validation errors, if any
"""
field_errors, kwargs = {}, {}
extended_profile_model = get_extended_profile_model()

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

return None, field_errors

return extended_profile_form, field_errors


def validate_and_get_extended_profile_form(
extended_profile_data: list, user: User
) -> tuple[forms.Form | None, 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 (forms.Form | None): 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