Skip to content

Commit 04cdfb7

Browse files
committed
feat: use extended profile model in the account settings
1 parent 2ae9cc9 commit 04cdfb7

3 files changed

Lines changed: 258 additions & 23 deletions

File tree

openedx/core/djangoapps/user_api/accounts/api.py

Lines changed: 185 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
"""
55

66
import datetime
7+
import logging
78
import re
9+
from typing import Optional
810

11+
from django import forms
912
from django.conf import settings
1013
from django.core.exceptions import ObjectDoesNotExist
1114
from django.core.validators import ValidationError, validate_email
@@ -36,7 +39,9 @@
3639
)
3740
from openedx.core.djangoapps.user_api.preferences.api import update_user_preferences
3841
from openedx.core.djangoapps.user_authn.utils import check_pwned_password
39-
from openedx.core.djangoapps.user_authn.views.registration_form import validate_name, validate_username
42+
from openedx.core.djangoapps.user_authn.views.registration_form import (
43+
get_extended_profile_model, get_registration_extension_form, validate_name, validate_username
44+
)
4045
from openedx.core.lib.api.view_utils import add_serializer_errors
4146
from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields
4247
from openedx.features.name_affirmation_api.utils import is_name_affirmation_installed
@@ -48,6 +53,8 @@
4853
# pylint: disable=import-error
4954
from edx_name_affirmation.name_change_validator import NameChangeValidator
5055

56+
logger = logging.getLogger(__name__)
57+
5158
# Public access point for this function.
5259
visible_fields = _visible_fields
5360

@@ -164,6 +171,7 @@ def update_account_settings(requesting_user, update, username=None):
164171

165172
old_name = _validate_name_change(user_profile, update, field_errors)
166173
old_language_proficiencies = _get_old_language_proficiencies_if_updating(user_profile, update)
174+
extended_profile_form = _get_and_validate_extended_profile_form(update, user, field_errors)
167175

168176
if field_errors:
169177
raise errors.AccountValidationError(field_errors)
@@ -176,7 +184,7 @@ def update_account_settings(requesting_user, update, username=None):
176184
_update_preferences_if_needed(update, requesting_user, user)
177185
_notify_language_proficiencies_update_if_needed(update, user, user_profile, old_language_proficiencies)
178186
_store_old_name_if_needed(old_name, user_profile, requesting_user)
179-
_update_extended_profile_if_needed(update, user_profile)
187+
_update_extended_profile_if_needed(update, user_profile, extended_profile_form)
180188
_update_state_if_needed(update, user_profile)
181189

182190
except PreferenceValidationError as err:
@@ -191,6 +199,141 @@ def update_account_settings(requesting_user, update, username=None):
191199
_send_email_change_requests_if_needed(update, user)
192200

193201

202+
def _get_and_validate_extended_profile_form(update: dict, user, field_errors: dict) -> Optional[forms.Form]:
203+
"""
204+
Get and validate the extended profile form if it exists in the update.
205+
206+
Args:
207+
update (dict): The update data containing potential extended_profile fields
208+
user (User): The user instance for whom the extended profile form is being validated
209+
field_errors (dict): Dictionary to collect field validation errors
210+
211+
Returns:
212+
Optional[forms.Form]: The validated extended profile form instance,
213+
or None if no extended profile form is needed
214+
"""
215+
extended_profile = update.get("extended_profile")
216+
if not extended_profile:
217+
return None
218+
219+
extended_profile_fields_data = _extract_extended_profile_fields_data(extended_profile, field_errors)
220+
if not extended_profile_fields_data:
221+
return None
222+
223+
extended_profile_form = _get_extended_profile_form_instance(extended_profile_fields_data, user, field_errors)
224+
if not extended_profile_form:
225+
return None
226+
227+
_validate_extended_profile_form_and_collect_errors(extended_profile_form, field_errors)
228+
229+
return extended_profile_form
230+
231+
232+
def _extract_extended_profile_fields_data(extended_profile: Optional[list], field_errors: dict) -> dict:
233+
"""
234+
Extract extended profile fields data from extended_profile structure.
235+
236+
Args:
237+
extended_profile (Optional[list]): List of field data dictionaries
238+
field_errors (dict): Dictionary to collect validation errors
239+
240+
Returns:
241+
dict: Extracted custom fields data
242+
"""
243+
if not isinstance(extended_profile, list):
244+
field_errors["extended_profile"] = {
245+
"developer_message": "extended_profile must be a list",
246+
"user_message": _("Invalid extended profile format"),
247+
}
248+
return {}
249+
250+
extended_profile_fields_data = {}
251+
252+
for field_data in extended_profile:
253+
if not isinstance(field_data, dict):
254+
logger.warning("Invalid field_data structure in extended_profile: %s", field_data)
255+
continue
256+
257+
field_name = field_data.get("field_name")
258+
field_value = field_data.get("field_value")
259+
260+
if not field_name:
261+
logger.warning("Missing field_name in extended_profile field_data: %s", field_data)
262+
continue
263+
264+
if field_value is not None:
265+
extended_profile_fields_data[field_name] = field_value
266+
267+
return extended_profile_fields_data
268+
269+
270+
def _get_extended_profile_form_instance(
271+
extended_profile_fields_data: dict, user, field_errors: dict
272+
) -> Optional[forms.Form]:
273+
"""
274+
Get or create an extended profile form instance.
275+
276+
Attempts to create a form instance using the configured `REGISTRATION_EXTENSION_FORM`.
277+
If an extended profile model exists, tries to bind to existing user data or creates
278+
a new instance. Handles import errors and missing configurations gracefully.
279+
280+
Args:
281+
extended_profile_fields_data (dict): Extended profile field data to populate the form
282+
user (User): User instance to associate with the extended profile
283+
field_errors (dict): Dictionary to collect validation errors if form creation fails
284+
285+
Returns:
286+
Optional[forms.Form]: Extended profile form instance with user data, or None if
287+
no extended profile form is configured or creation fails
288+
"""
289+
try:
290+
extended_profile_model = get_extended_profile_model()
291+
292+
kwargs = {}
293+
if not extended_profile_model:
294+
logger.info("No extended profile model configured")
295+
else:
296+
try:
297+
kwargs["instance"] = extended_profile_model.objects.get(user=user)
298+
except ObjectDoesNotExist:
299+
logger.info("No existing extended profile found for user %s, creating new instance", user.username)
300+
301+
extended_profile_form = get_registration_extension_form(data=extended_profile_fields_data, **kwargs)
302+
303+
return extended_profile_form
304+
305+
except ImportError as e:
306+
logger.warning("Extended profile model not available: %s", str(e))
307+
return None
308+
except Exception as e: # pylint: disable=broad-exception-caught
309+
logger.error("Unexpected error creating custom form for user %s: %s", user.username, str(e))
310+
field_errors["extended_profile"] = {
311+
"developer_message": f"Error creating custom form: {str(e)}",
312+
"user_message": _("There was an error processing the extended profile information"),
313+
}
314+
return None
315+
316+
317+
def _validate_extended_profile_form_and_collect_errors(extended_profile_form: forms.Form, field_errors: dict) -> None:
318+
"""
319+
Validate the extended profile form and collect any validation errors.
320+
321+
Args:
322+
extended_profile_form (forms.Form): The extended profile form to validate
323+
field_errors (dict): Dictionary to collect validation errors
324+
"""
325+
if not extended_profile_form.is_valid():
326+
logger.info("Extended profile form validation failed with errors: %s", extended_profile_form.errors)
327+
328+
for field_name, field_errors_list in extended_profile_form.errors.items():
329+
first_error = field_errors_list[0] if field_errors_list else "Unknown error"
330+
331+
field_errors[field_name] = {
332+
"developer_message": f"Error in extended profile field {field_name}: {first_error}",
333+
"user_message": str(first_error),
334+
}
335+
336+
194337
def _validate_read_only_fields(user, data, field_errors):
195338
# Check for fields that are not editable. Marking them read-only causes them to be ignored, but we wish to 400.
196339
read_only_fields = set(data.keys()).intersection(
@@ -346,17 +489,52 @@ def _notify_language_proficiencies_update_if_needed(data, user, user_profile, ol
346489
)
347490

348491

349-
def _update_extended_profile_if_needed(data, user_profile):
350-
if 'extended_profile' in data:
492+
def _update_extended_profile_if_needed(
493+
data: dict, user_profile: UserProfile, extended_profile_form: Optional[forms.Form]
494+
) -> None:
495+
"""
496+
Update the extended profile information if present in the data.
497+
498+
This function handles two types of extended profile updates:
499+
1. Updates the user profile meta fields with extended_profile data
500+
2. Saves the extended profile form data to the extended profile model if valid
501+
502+
Args:
503+
data (dict): Dictionary containing the update data, may include 'extended_profile' key
504+
user_profile (UserProfile): The UserProfile instance to update
505+
extended_profile_form (Optional[forms.Form]): The validated extended profile form
506+
containing extended profile data, or None if no extended profile form is provided
507+
508+
Note:
509+
If 'extended_profile' is present in data, the function will:
510+
- Extract field_name and field_value pairs from extended_profile list
511+
- Update the user_profile.meta dictionary with new values
512+
- Save the updated user_profile
513+
514+
If extended_profile_form is provided and valid, the function will:
515+
- Save the form data to the extended profile model
516+
- Associate the model instance with the user if it's a new instance
517+
- Log any errors that occur during the save process
518+
"""
519+
if "extended_profile" in data:
351520
meta = user_profile.get_meta()
352-
new_extended_profile = data['extended_profile']
521+
new_extended_profile = data["extended_profile"]
353522
for field in new_extended_profile:
354-
field_name = field['field_name']
355-
new_value = field['field_value']
523+
field_name = field["field_name"]
524+
new_value = field["field_value"]
356525
meta[field_name] = new_value
357526
user_profile.set_meta(meta)
358527
user_profile.save()
359528

529+
if extended_profile_form:
530+
try:
531+
extended_profile_model = extended_profile_form.save(commit=False)
532+
if not hasattr(extended_profile_model, "user") or extended_profile_model.user is None:
533+
extended_profile_model.user = user_profile.user
534+
extended_profile_model.save()
535+
except Exception as e: # pylint: disable=broad-exception-caught
536+
logger.error("Error saving extended profile model: %s", e)
537+
360538

361539
def _update_state_if_needed(data, user_profile):
362540
# If the country was changed to something other than US, remove the state.

openedx/core/djangoapps/user_api/accounts/serializers.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.conf import settings
1111
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
1212
from django.core.exceptions import ObjectDoesNotExist
13+
from django.forms.models import model_to_dict
1314
from django.urls import reverse
1415
from rest_framework import serializers
1516

@@ -26,7 +27,9 @@
2627
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
2728
from openedx.core.djangoapps.user_api.models import RetirementState, UserPreference, UserRetirementStatus
2829
from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
29-
from openedx.core.djangoapps.user_authn.views.registration_form import contains_html, contains_url
30+
from openedx.core.djangoapps.user_authn.views.registration_form import (
31+
contains_html, contains_url, get_extended_profile_model
32+
)
3033
from openedx.features.name_affirmation_api.utils import get_name_affirmation_service
3134

3235
from . import (
@@ -573,26 +576,47 @@ def validate_new_name(self, new_name):
573576
raise serializers.ValidationError('Name cannot contain a URL')
574577

575578

576-
def get_extended_profile(user_profile):
579+
def get_extended_profile(user_profile: UserProfile) -> list[dict[str, str]]:
577580
"""
578-
Returns the extended user profile fields stored in user_profile.meta
581+
Retrieve extended user profile fields for API serialization.
582+
583+
This function extracts custom profile fields that extend beyond the standard
584+
UserProfile model. It first attempts to get data from a custom extended profile
585+
model (if configured), then falls back to the user_profile.meta JSON field.
586+
The returned data is filtered to include only fields specified in the
587+
'extended_profile_fields' site configuration.
588+
589+
The function supports two data sources:
590+
1. Custom model: If `REGISTRATION_EXTENSION_FORM` setting points to a form with
591+
a `Meta.model`, data is retrieved from that model using `model_to_dict()`
592+
2. Fallback: JSON data stored in `UserProfile.meta` field
593+
594+
Args:
595+
user_profile (UserProfile): The user profile instance to get extended fields from.
596+
597+
Returns:
598+
list[dict[str, str]]: A list of dictionaries, each containing:
599+
- 'field_name': The name of the extended profile field
600+
- 'field_value': The value of the field (converted to string)
579601
"""
602+
def get_extended_profile_data():
603+
extended_profile_model = get_extended_profile_model()
580604

581-
# pick the keys from the site configuration
582-
extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', [])
605+
if extended_profile_model:
606+
try:
607+
profile_obj = extended_profile_model.objects.get(user=user_profile.user)
608+
return model_to_dict(profile_obj)
609+
except (AttributeError, extended_profile_model.DoesNotExist):
610+
return {}
583611

584-
try:
585-
extended_profile_fields_data = json.loads(user_profile.meta)
586-
except ValueError:
587-
extended_profile_fields_data = {}
612+
try:
613+
return json.loads(user_profile.meta or "{}")
614+
except (ValueError, TypeError, AttributeError):
615+
return {}
588616

589-
extended_profile = []
590-
for field_name in extended_profile_field_names:
591-
extended_profile.append({
592-
"field_name": field_name,
593-
"field_value": extended_profile_fields_data.get(field_name, "")
594-
})
595-
return extended_profile
617+
data = get_extended_profile_data()
618+
field_names = configuration_helpers.get_value("extended_profile_fields", [])
619+
return [{"field_name": name, "field_value": data.get(name, "")} for name in field_names]
596620

597621

598622
def get_profile_visibility(user_profile, user, configuration):

openedx/core/djangoapps/user_authn/views/registration_form.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import copy
66
import re
77
from importlib import import_module
8+
import logging
89

910
from django import forms
1011
from django.conf import settings
@@ -15,6 +16,8 @@
1516
from django.urls import reverse
1617
from django.utils.translation import gettext as _
1718
from django_countries import countries
19+
from django.db.models import Model
20+
from typing import Optional, Type
1821
from eventtracking import tracker
1922

2023
from common.djangoapps import third_party_auth
@@ -35,6 +38,8 @@
3538
from openedx.core.djangolib.markup import HTML, Text
3639
from openedx.features.enterprise_support.api import enterprise_customer_for_request
3740

41+
logger = logging.getLogger(__name__)
42+
3843

3944
class TrueCheckbox(widgets.CheckboxInput):
4045
"""
@@ -325,6 +330,34 @@ def get_registration_extension_form(*args, **kwargs):
325330
return getattr(module, klass)(*args, **kwargs)
326331

327332

333+
def get_extended_profile_model() -> Optional[Type[Model]]:
334+
"""
335+
Get the model class for the extended profile form.
336+
337+
Returns the Django model class associated with the form specified in
338+
the `REGISTRATION_EXTENSION_FORM` setting.
339+
340+
Returns:
341+
Optional[Type[Model]]: The model class if found and valid, None otherwise.
342+
343+
Example:
344+
# In settings.py: REGISTRATION_EXTENSION_FORM = 'myapp.forms.ExtendedForm'
345+
model_class = get_extended_profile_model()
346+
"""
347+
setting_value = getattr(settings, "REGISTRATION_EXTENSION_FORM", None)
348+
if not setting_value:
349+
return None
350+
351+
try:
352+
module_path, klass_name = setting_value.rsplit(".", 1)
353+
module = import_module(module_path)
354+
form_class = getattr(module, klass_name)
355+
return getattr(form_class.Meta, "model", None)
356+
except (ValueError, ImportError, ModuleNotFoundError, AttributeError) as e:
357+
logger.warning("Could not load extended profile model from '%s': %s", setting_value, e)
358+
return None
359+
360+
328361
class RegistrationFormFactory:
329362
"""
330363
Construct Registration forms and associated fields.

0 commit comments

Comments
 (0)