Skip to content
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
11 changes: 11 additions & 0 deletions eox_nelp/edxapp_wrapper/backends/user_authn_r_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
https://github.com/nelc/edx-platform/tree/open-release/redwood.nelp/openedx/core/djangoapps/user_authn
"""
from openedx.core.djangoapps.user_authn import views # pylint: disable=import-error
from openedx.core.djangoapps.user_authn.api import form_fields


def get_registration_form_factory():
Expand Down Expand Up @@ -32,3 +33,13 @@ def get_registration_extension_form():
get_registration_extension_form method.
"""
return views.registration_form.get_registration_extension_form


def get_api_form_fields():
"""Allow to get the module form_fields from
https://github.com/nelc/edx-platform/blob/open-release/redwood.nelp/openedx/core/djangoapps/user_authn/api/form_fields.py

Returns:
form_fields module.
"""
return form_fields
9 changes: 9 additions & 0 deletions eox_nelp/edxapp_wrapper/test_backends/user_authn_r_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,12 @@ def get_registration_extension_form():
Mock class.
"""
return Mock()


def get_api_form_fields():
"""Return mock form_fields module.

Returns:
Mock instance.
"""
return Mock()
2 changes: 2 additions & 0 deletions eox_nelp/edxapp_wrapper/user_authn.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
backend:Imported user_authn module by using the plugin settings.
registration_form_factory: Wrapper RegistrationFormFactory class.
get_registration_extension_form: Wrapper get_registration_extension_form method
form_fields: Wrapper form_fields module.
"""
from importlib import import_module

Expand All @@ -17,3 +18,4 @@
RegistrationFormFactory = backend.get_registration_form_factory()
views = backend.get_views()
get_registration_extension_form = backend.get_registration_extension_form()
form_fields = backend.get_api_form_fields()
46 changes: 44 additions & 2 deletions eox_nelp/init_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,38 @@

Functions:

run_init_pipeline: Wrapper for all the init methods, this avoids to import methods outside this file.
patch_user_gender_choices: Change the current openedx gender options (Male, Female, Other)
run_init_pipeline:
Executes all initialization processes required before the Django application starts.
Acts as an entry point to trigger the patching and setup routines defined below.

patch_user_gender_choices:
Updates the default Open edX gender field options to include only "Male" and "Female"
for compatibility with specific business rules.

set_mako_templates:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nice update of docstrings =)

Adds plugin template directories to the Mako configuration to ensure that
the custom templates are properly discovered at runtime.

register_xapi_transformers:
Imports and registers all available xAPI event transformers to enable event tracking.

update_permissions:
Adjusts and extends Open edX permission rules to support additional business roles
and use cases (e.g., data researcher, staff, instructor).

patch_generate_password:
Replaces the default `generate_password` implementation from `edx_django_utils`
with a custom NELP version for improved tenant-specific logic.

patch_registration_form_factory:
Overrides the default `RegistrationFormFactory` used in user authentication
with the custom NELP implementation to support extended registration logic.

patch_form_fields_getattr:
Dynamically patches the `form_fields` module to include a custom `__getattr__`
method, enabling runtime generation of field handlers based on configuration.
"""

import os

from django.utils.translation import gettext_noop
Expand All @@ -22,6 +50,7 @@ def run_init_pipeline():
update_permissions()
patch_generate_password()
patch_registration_form_factory()
patch_form_fields_getattr()


def patch_user_gender_choices():
Expand Down Expand Up @@ -113,3 +142,16 @@ def patch_registration_form_factory():
from eox_nelp.edxapp_wrapper.user_authn import views
from eox_nelp.user_authn.views.registration_form import NelpRegistrationFormFactory
views.registration_form.RegistrationFormFactory = NelpRegistrationFormFactory


def patch_form_fields_getattr():
"""
Patches the `form_fields` module within the Open edX user authentication app by dynamically
injecting a custom `__getattr__` method. This enables on-the-fly resolution of field handlers
(e.g., `add_<field>_field`) based on runtime configuration, allowing flexible field definition
without directly modifying the upstream module.
"""
# pylint: disable=import-outside-toplevel
from eox_nelp.edxapp_wrapper.user_authn import form_fields
from eox_nelp.user_authn.api.patches import form_field_getattr_patch
setattr(form_fields, "__getattr__", form_field_getattr_patch)
5 changes: 4 additions & 1 deletion eox_nelp/tests/test_init_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,18 @@
class RunInitPipelineTestCase(TestCase):
"""Test class for run_init_pipeline method."""

@patch("eox_nelp.init_pipeline.patch_form_fields_getattr")
@patch("eox_nelp.init_pipeline.patch_generate_password")
@patch("eox_nelp.init_pipeline.register_xapi_transformers")
@patch("eox_nelp.init_pipeline.patch_user_gender_choices")
@patch("eox_nelp.init_pipeline.set_mako_templates")
def test_pipeline_execute_expected_methods(
def test_pipeline_execute_expected_methods( # pylint: disable=too-many-arguments, too-many-positional-arguments
self,
set_mako_templates_mock,
patch_user_gender_choices_mock,
register_xapi_transformers_mock,
patch_generate_password_mock,
patch_form_fields_getattr_mock,
):
""" Test that method calls the expected methods during the pipeline execution.

Expand All @@ -41,6 +43,7 @@ def test_pipeline_execute_expected_methods(
patch_user_gender_choices_mock.assert_called_once()
register_xapi_transformers_mock.assert_called_once()
patch_generate_password_mock.assert_called_once()
patch_form_fields_getattr_mock.assert_called_once()


class SetMakoTemplatesTestCase(TestCase):
Expand Down
Empty file.
82 changes: 82 additions & 0 deletions eox_nelp/user_authn/api/patches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""
This module defines utility functions for dynamic runtime patching.

Functions:

form_field_getattr_patch:
Defines a dynamic attribute resolver that returns a callable when
the accessed attribute matches a specific naming pattern.
"""
import re

from crum import get_current_request

from eox_nelp.edxapp_wrapper.site_configuration import configuration_helpers
from eox_nelp.edxapp_wrapper.user_authn import form_fields


def form_field_getattr_patch(attribute):
"""
Resolves dynamic attribute access based on a naming pattern.

If the given attribute name matches the expected pattern (e.g. `add_<field_name>_field`)
and the corresponding field is listed in the site configuration, a callable is returned
that can generate the field definition at runtime.

Args:
attribute (str): Attribute name being accessed.

Returns:
function: Callable that generates a form field definition.

Raises:
AttributeError: If the attribute name does not correspond to a configured field.
"""

def _generate_handler(field_name):
"""
Creates a callable for generating a form field definition.

This helper retrieves field metadata such as label translations from
site configuration and prepares a function that can be invoked to
construct the appropriate form field.

Args:
field_name (str): Name of the field to generate.

Returns:
function: Callable that, when executed, creates the field definition.
"""
request = get_current_request()

extended_profile_fields_translations = configuration_helpers.get_value(
"extended_profile_fields_translations",
{},
)
translations = extended_profile_fields_translations.get(request.LANGUAGE_CODE, {})
label = translations.get(field_name, field_name).capitalize()

def handler(is_field_required=True):
"""
Creates a form field using the given parameters.

Args:
is_field_required (bool): Whether the field is mandatory. Defaults to True.

Returns:
Any: Field instance created by the underlying form field utility.
"""
# pylint: disable=protected-access
return form_fields._add_field_with_configurable_select_options(field_name, label, is_field_required)

return handler

pattern = r"^add_(?P<field_name>.+?)_field$"

field_name = re.match(pattern, attribute).group('field_name')
extended_profile_fields = configuration_helpers.get_value("extended_profile_fields", [])

if field_name in extended_profile_fields:
return _generate_handler(field_name)

raise AttributeError(f"Invalid attribute: '{attribute}'.")
Empty file.
90 changes: 90 additions & 0 deletions eox_nelp/user_authn/tests/api/test_patches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""This file contains all the tests for the dynamic patch utilities.

Classes:
FormFieldGetAttrPatchTestCase: Tests for dynamic attribute resolution and field handler generation.
"""
from django.test import TestCase
from mock import Mock, patch

from eox_nelp.edxapp_wrapper.site_configuration import configuration_helpers
from eox_nelp.user_authn.api import patches


class FormFieldGetAttrPatchTestCase(TestCase):
"""Tests for form_field_getattr_patch function."""

def setUp(self):
"""
Set up mocks for configuration and request context.
"""
self.mock_request = Mock()
self.mock_request.LANGUAGE_CODE = "es"

def tearDown(self):
"""Reset mocks."""
configuration_helpers.reset_mock()
self.mock_request.reset_mock()

@patch("eox_nelp.user_authn.api.patches.get_current_request")
@patch("eox_nelp.user_authn.api.patches.form_fields._add_field_with_configurable_select_options")
def test_dynamic_handler_generation(self, mock_add_field, mock_get_request):
"""Test that form_field_getattr_patch generates a handler for configured fields.

Expected behaviour:
- When attribute name matches `add_<field>_field`, and field is configured,
a callable is returned.
"""
field_name = "hobby"
attribute = f"add_{field_name}_field"
mock_get_request.return_value = self.mock_request
configuration_helpers.get_value.side_effect = lambda key, default=None: (
["hobby", "sport", "movie"]
if key == "extended_profile_fields"
else {}
)

handler = patches.form_field_getattr_patch(attribute)
handler() # invoke handler

self.assertTrue(callable(handler))
mock_add_field.assert_called_once_with(field_name, field_name.capitalize(), True)

@patch("eox_nelp.user_authn.api.patches.get_current_request")
@patch("eox_nelp.user_authn.api.patches.form_fields._add_field_with_configurable_select_options")
def test_dynamic_handler_with_translations(self, mock_add_field, mock_get_request):
"""Test that form_field_getattr_patch applies translated labels when available.

Expected behaviour:
- Translation for the field is fetched from `extended_profile_fields_translations`.
- The label passed to `_add_field_with_configurable_select_options` should use
the localized version instead of the field name.
"""
field_name = "sport"
translated_label = "Deporte"
attribute = f"add_{field_name}_field"
mock_get_request.return_value = self.mock_request
configuration_helpers.get_value.side_effect = lambda key, default=None: {
"extended_profile_fields": ["sport"],
"extended_profile_fields_translations": {
"es": {field_name: translated_label},
},
}.get(key, default)

handler = patches.form_field_getattr_patch(attribute)
handler(is_field_required=False)

self.assertTrue(callable(handler))
mock_add_field.assert_called_once_with(field_name, translated_label, False)

def test_attribute_error_for_unconfigured_field(self):
"""Test that AttributeError is raised for unconfigured fields.

Expected behaviour:
- When the field is not included in the site configuration, calling
form_field_getattr_patch raises AttributeError.
"""
configuration_helpers.get_value.side_effect = lambda key, default=None: []
attribute = "add_unknown_field"

with self.assertRaises(AttributeError):
patches.form_field_getattr_patch(attribute)
Loading