Skip to content

Commit 82b7046

Browse files
committed
feat: use extended profile model in the account settings
1 parent 3c69457 commit 82b7046

File tree

3 files changed

+258
-23
lines changed

3 files changed

+258
-23
lines changed

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

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

66

77
import datetime
8+
import logging
89
import re
10+
from typing import Optional
911

12+
from django import forms
1013
from django.conf import settings
1114
from django.core.exceptions import ObjectDoesNotExist
1215
from django.core.validators import ValidationError, validate_email
@@ -37,7 +40,9 @@
3740
)
3841
from openedx.core.djangoapps.user_api.preferences.api import update_user_preferences
3942
from openedx.core.djangoapps.user_authn.utils import check_pwned_password
40-
from openedx.core.djangoapps.user_authn.views.registration_form import validate_name, validate_username
43+
from openedx.core.djangoapps.user_authn.views.registration_form import (
44+
get_extended_profile_model, get_registration_extension_form, validate_name, validate_username
45+
)
4146
from openedx.core.lib.api.view_utils import add_serializer_errors
4247
from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields
4348
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

@@ -155,6 +162,7 @@ def update_account_settings(requesting_user, update, username=None):
155162
_validate_secondary_email(user, update, field_errors)
156163
old_name = _validate_name_change(user_profile, update, field_errors)
157164
old_language_proficiencies = _get_old_language_proficiencies_if_updating(user_profile, update)
165+
extended_profile_form = _get_and_validate_extended_profile_form(update, user, field_errors)
158166

159167
if field_errors:
160168
raise errors.AccountValidationError(field_errors)
@@ -167,7 +175,7 @@ def update_account_settings(requesting_user, update, username=None):
167175
_update_preferences_if_needed(update, requesting_user, user)
168176
_notify_language_proficiencies_update_if_needed(update, user, user_profile, old_language_proficiencies)
169177
_store_old_name_if_needed(old_name, user_profile, requesting_user)
170-
_update_extended_profile_if_needed(update, user_profile)
178+
_update_extended_profile_if_needed(update, user_profile, extended_profile_form)
171179
_update_state_if_needed(update, user_profile)
172180

173181
# Allow a plugin to save the updated values
@@ -189,6 +197,141 @@ def update_account_settings(requesting_user, update, username=None):
189197
_send_email_change_requests_if_needed(update, user)
190198

191199

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

346489

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

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

359537
def _update_state_if_needed(data, user_profile):
360538
# 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

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

3336
from . import (
@@ -569,26 +572,47 @@ def validate_new_name(self, new_name):
569572
raise serializers.ValidationError('Name cannot contain a URL')
570573

571574

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

577-
# pick the keys from the site configuration
578-
extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', [])
601+
if extended_profile_model:
602+
try:
603+
profile_obj = extended_profile_model.objects.get(user=user_profile.user)
604+
return model_to_dict(profile_obj)
605+
except (AttributeError, extended_profile_model.DoesNotExist):
606+
return {}
579607

580-
try:
581-
extended_profile_fields_data = json.loads(user_profile.meta)
582-
except ValueError:
583-
extended_profile_fields_data = {}
608+
try:
609+
return json.loads(user_profile.meta or "{}")
610+
except (ValueError, TypeError, AttributeError):
611+
return {}
584612

585-
extended_profile = []
586-
for field_name in extended_profile_field_names:
587-
extended_profile.append({
588-
"field_name": field_name,
589-
"field_value": extended_profile_fields_data.get(field_name, "")
590-
})
591-
return extended_profile
613+
data = get_extended_profile_data()
614+
field_names = configuration_helpers.get_value("extended_profile_fields", [])
615+
return [{"field_name": name, "field_value": data.get(name, "")} for name in field_names]
592616

593617

594618
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
@@ -6,6 +6,7 @@
66
from importlib import import_module
77
from eventtracking import tracker
88
import re
9+
import logging
910

1011
from django import forms
1112
from django.conf import settings
@@ -16,6 +17,8 @@
1617
from django.urls import reverse
1718
from django.utils.translation import gettext as _
1819
from django_countries import countries
20+
from django.db.models import Model
21+
from typing import Optional, Type
1922

2023
from common.djangoapps import third_party_auth
2124
from common.djangoapps.edxmako.shortcuts import marketing_link
@@ -37,6 +40,8 @@
3740
validate_password,
3841
)
3942

43+
logger = logging.getLogger(__name__)
44+
4045

4146
class TrueCheckbox(widgets.CheckboxInput):
4247
"""
@@ -312,6 +317,34 @@ def get_registration_extension_form(*args, **kwargs):
312317
return getattr(module, klass)(*args, **kwargs)
313318

314319

320+
def get_extended_profile_model() -> Optional[Type[Model]]:
321+
"""
322+
Get the model class for the extended profile form.
323+
324+
Returns the Django model class associated with the form specified in
325+
the `REGISTRATION_EXTENSION_FORM` setting.
326+
327+
Returns:
328+
Optional[Type[Model]]: The model class if found and valid, None otherwise.
329+
330+
Example:
331+
# In settings.py: REGISTRATION_EXTENSION_FORM = 'myapp.forms.ExtendedForm'
332+
model_class = get_extended_profile_model()
333+
"""
334+
setting_value = getattr(settings, "REGISTRATION_EXTENSION_FORM", None)
335+
if not setting_value:
336+
return None
337+
338+
try:
339+
module_path, klass_name = setting_value.rsplit(".", 1)
340+
module = import_module(module_path)
341+
form_class = getattr(module, klass_name)
342+
return getattr(form_class.Meta, "model", None)
343+
except (ValueError, ImportError, ModuleNotFoundError, AttributeError) as e:
344+
logger.warning("Could not load extended profile model from '%s': %s", setting_value, e)
345+
return None
346+
347+
315348
class RegistrationFormFactory:
316349
"""
317350
Construct Registration forms and associated fields.

0 commit comments

Comments
 (0)