Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add custom field on register form #773

Merged
Merged
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
104 changes: 104 additions & 0 deletions openedx/core/djangoapps/user_api/accounts/settings_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods
from django_countries import countries

from openedx_filters import PipelineStep
from openedx_filters.tooling import OpenEdxPublicFilter
from openedx_filters.exceptions import OpenEdxFilterException
from common.djangoapps import third_party_auth
from common.djangoapps.edxmako.shortcuts import render_to_response
from lms.djangoapps.commerce.models import CommerceConfiguration
Expand All @@ -39,6 +43,100 @@
log = logging.getLogger(__name__)


class AddCustomOptionsOnAccountSettings(PipelineStep):
""" Pipeline used to add custom option fields in account settings.

Example usage:

Add the following configurations to your configuration file:
"OPEN_EDX_FILTERS_CONFIG": {
"org.openedx.learning.student.settings.render.started.v1": {
"fail_silently": false,
"pipeline": [
"eox_tenant.samples.pipeline.AddCustomOptionsOnAccountSettings"
]
}
}
"""

def run_filter(self, context): # pylint: disable=arguments-differ
""" Run the pipeline filter. """
extended_profile_fields = context.get("extended_profile_fields", [])

custom_options, field_labels_map = self._get_custom_context(extended_profile_fields) # pylint: disable=line-too-long

extended_profile_field_options = configuration_helpers.get_value('EXTRA_FIELD_OPTIONS', custom_options) # pylint: disable=line-too-long
extended_profile_field_option_tuples = {}
for field in extended_profile_field_options.keys():
field_options = extended_profile_field_options[field]
extended_profile_field_option_tuples[field] = [(option.lower(), option) for option in field_options] # pylint: disable=line-too-long

for field in custom_options:
field_dict = {
"field_name": field,
"field_label": field_labels_map.get(field, field),
}

field_options = extended_profile_field_option_tuples.get(field)
if field_options:
field_dict["field_type"] = "ListField"
field_dict["field_options"] = field_options
else:
field_dict["field_type"] = "TextField"

field_index = next((index for (index, d) in enumerate(extended_profile_fields) if d["field_name"] == field_dict["field_name"]), None) # pylint: disable=line-too-long
if field_index is not None:
context["extended_profile_fields"][field_index] = field_dict
return context

def _get_custom_context(self, extended_profile_fields):
""" Get custom context for the field. """
field_labels = {}
field_options = {}
custom_fields = getattr(settings, "EDNX_CUSTOM_REGISTRATION_FIELDS", [])

for field in custom_fields:
field_name = field.get("name")

if not field_name: # Required to identify the field.
msg = "Custom fields must have a `name` defined in their configuration."
raise ImproperlyConfigured(msg)

field_label = field.get("label")
if not any(extended_field['field_name'] == field_name for extended_field in extended_profile_fields) and field_label: # pylint: disable=line-too-long
field_labels[field_name] = _(field_label) # pylint: disable=translation-of-non-string

options = field.get("options")

if options:
field_options[field_name] = options

return field_options, field_labels


class AccountSettingsRenderStarted(OpenEdxPublicFilter):
""" Custom class used to create Account settings filters. """

filter_type = "org.openedx.learning.student.settings.render.started.v1"

class ErrorFilteringContext(OpenEdxFilterException):
"""
Custom class used catch exceptions when filtering the context.
"""

@classmethod
def run_filter(cls, context):
"""
Execute a filter with the signature specified.

Arguments:
context (dict): context for the account settings page.
"""
data = super().run_pipeline(context=context)
context = data.get("context")
return context


@login_required
@require_http_methods(['GET'])
def account_settings(request):
Expand Down Expand Up @@ -72,6 +170,12 @@ def account_settings(request):
return redirect(url)

context = account_settings_context(request)

try:
context = AccountSettingsRenderStarted().run_filter(context=context)
except AccountSettingsRenderStarted.ErrorFilteringContext as exc:
raise ImproperlyConfigured(f'Pipeline configuration error: {exc}') from exc

return render_to_response('student_account/account_settings.html', context)


Expand Down
178 changes: 178 additions & 0 deletions openedx/core/djangoapps/user_authn/views/registration_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import copy
from collections import OrderedDict
from importlib import import_module
import re

Expand All @@ -16,6 +17,9 @@
from django.utils.translation import gettext as _
from django_countries import countries

from openedx_filters import PipelineStep
from openedx_filters.tooling import OpenEdxPublicFilter
from openedx_filters.exceptions import OpenEdxFilterException
from common.djangoapps import third_party_auth
from common.djangoapps.edxmako.shortcuts import marketing_link
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
Expand All @@ -36,6 +40,174 @@
)


class AddCustomFieldsBeforeRegistration(PipelineStep):
""" Pipeline used to add custom fields to the registration form.

Example usage:

Add the following configurations to your configuration file:
"OPEN_EDX_FILTERS_CONFIG": {
"org.openedx.learning.student.registration.render.started.v1": {
"fail_silently": false,
"pipeline": [
"eox_tenant.samples.pipeline.AddCustomFieldsBeforeRegistration"
]
}
}
"""

def run_filter(self, form_desc): # pylint: disable=arguments-differ
"""Run the pipeline filter."""

extra_fields = self._get_extra_fields()

for field_name in extra_fields:
extra_field = {"field_name": field_name}
self._add_custom_field(
form_desc,
required=self._is_field_required(field_name),
**extra_field
)
fields = form_desc.fields
fields = [field['name'] for field in fields]

field_order = configuration_helpers.get_value('REGISTRATION_FIELD_ORDER')
if not field_order:
field_order = settings.REGISTRATION_FIELD_ORDER or fields

if set(fields) != set(field_order):
difference = set(fields).difference(set(field_order))
field_order.extend(sorted(difference))

ordered_fields = []
for field in field_order:
for form_field in form_desc.fields:
if field == form_field['name']:
ordered_fields.append(form_field)
break

form_desc.fields = ordered_fields
return form_desc

def _get_extra_fields(self):
"""Returns the list of extra fields to include in the registration form.
Returns:
list of strings
"""
extended_profile_fields = [field.lower() for field in getattr(settings, 'extended_profile_fields', [])] # lint-amnesty, pylint: disable=line-too-long

return list(OrderedDict.fromkeys(extended_profile_fields))

def _is_field_required(self, field_name):
"""Check whether a field is required based on Django settings. """
_extra_fields_setting = copy.deepcopy(
configuration_helpers.get_value('REGISTRATION_EXTRA_FIELDS')
)
if not _extra_fields_setting:
_extra_fields_setting = copy.deepcopy(settings.REGISTRATION_EXTRA_FIELDS)

return _extra_fields_setting.get(field_name) == "required"

def _get_custom_field_dict(self, field_name):
"""Given a field name searches for its definition dictionary.
Arguments:
field_name (str): the name of the field to search for.
"""
custom_fields = getattr(settings, "EDNX_CUSTOM_REGISTRATION_FIELDS", [])
for field in custom_fields:
if field.get("name").lower() == field_name:
return field
return {}

def _get_custom_html_override(self, text_field, html_piece=None):
"""Overrides field with html piece.
Arguments:
text_field: field to override. It must have the following format:
"Here {} goes the HTML piece." In `{}` will be inserted the HTML piece.
Keyword Arguments:
html_piece: string containing HTML components to be inserted.
"""
if html_piece:
html_piece = HTML(html_piece) if isinstance(html_piece, str) else ""
return Text(_(text_field)).format(html_piece) # pylint: disable=translation-of-non-string
return text_field

def _add_custom_field(self, form_desc, required=True, **kwargs):
"""Adds custom fields to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (bool): Whether this field is required; defaults to False
field_name (str): Name used to get field information when creating it.
"""
field_name = kwargs.pop("field_name")
if field_name in getattr(settings, "EDNX_IGNORE_REGISTER_FIELDS", []):
return

custom_field_dict = self._get_custom_field_dict(field_name)
if not custom_field_dict:
return

# Check to convert options:
field_options = custom_field_dict.get("options")
if isinstance(field_options, dict):
field_options = [(str(value.lower()), name) for value, name in field_options.items()]
elif isinstance(field_options, list):
field_options = [(str(value.lower()), value) for value in field_options]

# Set default option if applies:
default_option = custom_field_dict.get("default")
if default_option:
form_desc.override_field_properties(
field_name,
default=default_option
)

field_type = custom_field_dict.get("type")

form_desc.add_field(
field_name,
label=self._get_custom_html_override(
custom_field_dict.get("label"),
custom_field_dict.get("html_override"),
),
field_type=field_type,
options=field_options,
instructions=custom_field_dict.get("instructions"),
placeholder=custom_field_dict.get("placeholder"),
restrictions=custom_field_dict.get("restrictions"),
include_default_option=bool(default_option) or field_type == "select",
required=required,
error_messages=custom_field_dict.get("errorMessages")
)


class StudentRegistrationRenderStarted(OpenEdxPublicFilter):
"""
Custom class used to add custom fields to the registration form.
"""

filter_type = "org.openedx.learning.student.registration.render.started.v1"

class ErrorFilteringFormDescription(OpenEdxFilterException):
"""
Custom class used catch exceptions when filtering form description.
"""

@classmethod
def run_filter(cls, form_desc):
"""
Execute a filter with the signature specified.

Arguments:
form_desc (dict): description form specifying the fields to be rendered
in the registration form.
"""
data = super().run_pipeline(form_desc=form_desc)
form_data = data.get("form_desc")
return form_data


class TrueCheckbox(widgets.CheckboxInput):
"""
A checkbox widget that only accepts "true" (case-insensitive) as true.
Expand Down Expand Up @@ -455,6 +627,12 @@ def get_registration_form(self, request):
if field['name'] == 'confirm_email':
del form_desc.fields[index]
break

try:
form_desc = StudentRegistrationRenderStarted().run_filter(form_desc)
except StudentRegistrationRenderStarted.ErrorFilteringFormDescription as exc:
raise ImproperlyConfigured(f'Pipeline configuration error: {exc}') from exc

return form_desc

def _get_registration_submit_url(self, request):
Expand Down
Loading