From d660d0c856c0e6922aec537e830e7af8ab36de2a Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Tue, 22 Aug 2023 11:43:38 +0500 Subject: [PATCH 1/2] autofilling user details from sso pipeline (#33051) * refactor: mfe_context response to serialize object keys to camelcase (#31930) * test: add tests for MFE Context API serializser (#32179) * fix: empty pipelineUserDetail object pipelineUserDetail object should be empty when pipeline is not running --------- Co-authored-by: Shahbaz Shabbir <32649010+shahbaz-arbisoft@users.noreply.github.com> --- .../user_authn/api/tests/test_data.py | 141 ++++++++++++++++++ .../user_authn/api/tests/test_serializers.py | 44 ++++++ .../user_authn/api/tests/test_views.py | 72 +++++++-- .../core/djangoapps/user_authn/api/views.py | 6 +- .../core/djangoapps/user_authn/serializers.py | 82 ++++++++++ 5 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 openedx/core/djangoapps/user_authn/api/tests/test_data.py create mode 100644 openedx/core/djangoapps/user_authn/api/tests/test_serializers.py create mode 100644 openedx/core/djangoapps/user_authn/serializers.py diff --git a/openedx/core/djangoapps/user_authn/api/tests/test_data.py b/openedx/core/djangoapps/user_authn/api/tests/test_data.py new file mode 100644 index 000000000000..05d0f7cf6b8e --- /dev/null +++ b/openedx/core/djangoapps/user_authn/api/tests/test_data.py @@ -0,0 +1,141 @@ +""" Mocked data for testing """ + +mfe_context_data_keys = { + 'contextData', + 'registrationFields', + 'optionalFields' +} + +mock_mfe_context_data = { + 'context_data': { + 'currentProvider': 'edX', + 'platformName': 'edX', + 'providers': [ + { + 'id': 'oa2-facebook', + 'name': 'Facebook', + 'iconClass': 'fa-facebook', + 'iconImage': None, + 'skipHintedLogin': False, + 'skipRegistrationForm': False, + 'loginUrl': 'https://facebook.com/login', + 'registerUrl': 'https://facebook.com/register' + }, + { + 'id': 'oa2-google-oauth2', + 'name': 'Google', + 'iconClass': 'fa-google-plus', + 'iconImage': None, + 'skipHintedLogin': False, + 'skipRegistrationForm': False, + 'loginUrl': 'https://google.com/login', + 'registerUrl': 'https://google.com/register' + } + ], + 'secondaryProviders': [], + 'finishAuthUrl': 'https://edx.com/auth/finish', + 'errorMessage': None, + 'registerFormSubmitButtonText': 'Create Account', + 'autoSubmitRegForm': False, + 'syncLearnerProfileData': False, + 'countryCode': '', + 'pipeline_user_details': { + 'username': 'test123', + 'email': 'test123@edx.com', + 'fullname': 'Test Test', + 'first_name': 'Test', + 'last_name': 'Test' + } + }, + 'registration_fields': {}, + 'optional_fields': { + 'extended_profile': [] + } +} + +mock_default_mfe_context_data = { + 'context_data': { + 'currentProvider': None, + 'platformName': 'édX', + 'providers': [], + 'secondaryProviders': [], + 'finishAuthUrl': None, + 'errorMessage': None, + 'registerFormSubmitButtonText': 'Create Account', + 'autoSubmitRegForm': False, + 'syncLearnerProfileData': False, + 'countryCode': '', + 'pipeline_user_details': {} + }, + 'registration_fields': {}, + 'optional_fields': { + 'extended_profile': [] + } +} + +expected_mfe_context_data = { + 'contextData': { + 'currentProvider': 'edX', + 'platformName': 'edX', + 'providers': [ + { + 'id': 'oa2-facebook', + 'name': 'Facebook', + 'iconClass': 'fa-facebook', + 'iconImage': None, + 'skipHintedLogin': False, + 'skipRegistrationForm': False, + 'loginUrl': 'https://facebook.com/login', + 'registerUrl': 'https://facebook.com/register' + }, + { + 'id': 'oa2-google-oauth2', + 'name': 'Google', + 'iconClass': 'fa-google-plus', + 'iconImage': None, + 'skipHintedLogin': False, + 'skipRegistrationForm': False, + 'loginUrl': 'https://google.com/login', + 'registerUrl': 'https://google.com/register' + } + ], + 'secondaryProviders': [], + 'finishAuthUrl': 'https://edx.com/auth/finish', + 'errorMessage': None, + 'registerFormSubmitButtonText': 'Create Account', + 'autoSubmitRegForm': False, + 'syncLearnerProfileData': False, + 'countryCode': '', + 'pipelineUserDetails': { + 'username': 'test123', + 'email': 'test123@edx.com', + 'name': 'Test Test', + 'firstName': 'Test', + 'lastName': 'Test' + } + }, + 'registrationFields': {}, + 'optionalFields': { + 'extended_profile': [] + } +} + +default_expected_mfe_context_data = { + 'contextData': { + 'currentProvider': None, + 'platformName': 'édX', + 'providers': [], + 'secondaryProviders': [], + 'finishAuthUrl': None, + 'errorMessage': None, + 'registerFormSubmitButtonText': 'Create Account', + 'autoSubmitRegForm': False, + 'syncLearnerProfileData': False, + 'countryCode': '', + 'pipelineUserDetails': {} + }, + 'registrationFields': {}, + 'optionalFields': { + 'extended_profile': [] + } +} diff --git a/openedx/core/djangoapps/user_authn/api/tests/test_serializers.py b/openedx/core/djangoapps/user_authn/api/tests/test_serializers.py new file mode 100644 index 000000000000..348292b172f0 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/api/tests/test_serializers.py @@ -0,0 +1,44 @@ +"""Tests for serializers for the MFE Context""" + +from django.test import TestCase + +from openedx.core.djangoapps.user_authn.api.tests.test_data import ( + mock_mfe_context_data, + expected_mfe_context_data, + mock_default_mfe_context_data, + default_expected_mfe_context_data, +) +from openedx.core.djangoapps.user_authn.serializers import MFEContextSerializer + + +class TestMFEContextSerializer(TestCase): + """ + High-level unit tests for MFEContextSerializer + """ + + def test_mfe_context_serializer(self): + """ + Test MFEContextSerializer with mock data that serializes data correctly + """ + + output_data = MFEContextSerializer( + mock_mfe_context_data + ).data + + self.assertDictEqual( + output_data, + expected_mfe_context_data + ) + + def test_mfe_context_serializer_default_response(self): + """ + Test MFEContextSerializer with default data + """ + serialized_data = MFEContextSerializer( + mock_default_mfe_context_data + ).data + + self.assertDictEqual( + serialized_data, + default_expected_mfe_context_data + ) diff --git a/openedx/core/djangoapps/user_authn/api/tests/test_views.py b/openedx/core/djangoapps/user_authn/api/tests/test_views.py index af079f6d7ca2..d8ab2c37c1a1 100644 --- a/openedx/core/djangoapps/user_authn/api/tests/test_views.py +++ b/openedx/core/djangoapps/user_authn/api/tests/test_views.py @@ -16,9 +16,10 @@ from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.third_party_auth import pipeline from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline -from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration from openedx.core.djangoapps.geoinfo.api import country_code_from_ip +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase +from openedx.core.djangoapps.user_authn.api.tests.test_data import mfe_context_data_keys from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -42,6 +43,7 @@ def setUp(self): # pylint: disable=arguments-differ hostname = socket.gethostname() ip_address = socket.gethostbyname(hostname) self.country_code = country_code_from_ip(ip_address) + self.pipeline_user_details = {} # Several third party auth providers are created for these tests: self.configure_google_provider(enabled=True, visible=True) @@ -93,8 +95,20 @@ def get_context(self, params=None, current_provider=None, backend_name=None, add """ Returns the MFE context """ + + if add_user_details: + self.pipeline_user_details.update( + { + 'username': None, + 'email': 'test@test.com', + 'name': None, + 'firstName': None, + 'lastName': None + } + ) + return { - 'context_data': { + 'contextData': { 'currentProvider': current_provider, 'platformName': settings.PLATFORM_NAME, 'providers': self.get_provider_data(params) if params else [], @@ -102,12 +116,13 @@ def get_context(self, params=None, current_provider=None, backend_name=None, add 'finishAuthUrl': pipeline.get_complete_url(backend_name) if backend_name else None, 'errorMessage': None, 'registerFormSubmitButtonText': 'Create Account', + 'autoSubmitRegForm': False, 'syncLearnerProfileData': False, - 'pipeline_user_details': {'email': 'test@test.com'} if add_user_details else {}, - 'countryCode': self.country_code + 'countryCode': self.country_code, + 'pipelineUserDetails': self.pipeline_user_details, }, - 'registration_fields': {}, - 'optional_fields': { + 'registrationFields': {}, + 'optionalFields': { 'extended_profile': [], }, } @@ -182,7 +197,7 @@ def test_tpa_hint(self): }) response = self.client.get(self.url, self.query_params) - assert response.data['context_data']['providers'] == provider_data + assert response.data['contextData']['providers'] == provider_data def test_user_country_code(self): """ @@ -191,7 +206,7 @@ def test_user_country_code(self): response = self.client.get(self.url, self.query_params) assert response.status_code == 200 - assert response.data['context_data']['countryCode'] == self.country_code + assert response.data['contextData']['countryCode'] == self.country_code @override_settings( ENABLE_DYNAMIC_REGISTRATION_FIELDS=True, @@ -205,7 +220,7 @@ def test_required_fields_not_configured(self): self.query_params.update({'is_register_page': True}) response = self.client.get(self.url, self.query_params) assert response.status_code == status.HTTP_200_OK - assert response.data['registration_fields']['fields'] == {} + assert response.data['registrationFields']['fields'] == {} @with_site_configuration( configuration={ @@ -223,8 +238,9 @@ def test_required_field_order(self): """ self.query_params.update({'is_register_page': True}) response = self.client.get(self.url, self.query_params) + assert response.status_code == status.HTTP_200_OK - assert list(response.data['registration_fields']['fields'].keys()) == ['first_name', 'last_name', 'state'] + assert list(response.data['registrationFields']['fields'].keys()) == ['first_name', 'last_name', 'state'] @override_settings( ENABLE_DYNAMIC_REGISTRATION_FIELDS=True, @@ -248,7 +264,7 @@ def test_optional_field_has_no_description(self): self.query_params.update({'is_register_page': True}) response = self.client.get(self.url, self.query_params) assert response.status_code == status.HTTP_200_OK - assert response.data['optional_fields']['fields'] == expected_response + assert response.data['optionalFields']['fields'] == expected_response @with_site_configuration( configuration={ @@ -282,8 +298,9 @@ def test_configurable_select_option_fields(self): } self.query_params.update({'is_register_page': True}) response = self.client.get(self.url, self.query_params) + assert response.status_code == status.HTTP_200_OK - assert response.data['optional_fields']['fields'] == expected_response + assert response.data['optionalFields']['fields'] == expected_response @with_site_configuration( configuration={ @@ -302,7 +319,7 @@ def test_optional_field_order(self): self.query_params.update({'is_register_page': True}) response = self.client.get(self.url, self.query_params) assert response.status_code == status.HTTP_200_OK - assert list(response.data['optional_fields']['fields'].keys()) == ['specialty', 'goals'] + assert list(response.data['optionalFields']['fields'].keys()) == ['specialty', 'goals'] @with_site_configuration( configuration={ @@ -322,7 +339,7 @@ def test_field_not_available_in_extended_profile_config(self): self.query_params.update({'is_register_page': True}) response = self.client.get(self.url, self.query_params) assert response.status_code == status.HTTP_200_OK - assert list(response.data['registration_fields']['fields'].keys()) == ['specialty'] + assert list(response.data['registrationFields']['fields'].keys()) == ['specialty'] @override_settings( ENABLE_DYNAMIC_REGISTRATION_FIELDS=True, @@ -333,9 +350,34 @@ def test_response_structure(self): Test that API return valid response dictionary with both required and optional fields """ response = self.client.get(self.url, self.query_params) - assert response.data == self.get_context() + def test_mfe_context_api_serialized_response(self): + """ + Test MFE Context API serialized response + """ + response = self.client.get(self.url, self.query_params) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + params = { + 'next': self.query_params['next'] + } + + self.assertEqual( + response.data, + self.get_context(params) + ) + + def test_mfe_context_api_response_keys(self): + """ + Test MFE Context API response keys + """ + response = self.client.get(self.url, self.query_params) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response_keys = set(response.data.keys()) + self.assertSetEqual(response_keys, mfe_context_data_keys) + @skip_unless_lms class SendAccountActivationEmail(UserAPITestCase): diff --git a/openedx/core/djangoapps/user_authn/api/views.py b/openedx/core/djangoapps/user_authn/api/views.py index 7e84a6747301..2fbc014f52fb 100644 --- a/openedx/core/djangoapps/user_authn/api/views.py +++ b/openedx/core/djangoapps/user_authn/api/views.py @@ -15,6 +15,7 @@ from common.djangoapps.student.views import compose_and_send_activation_email from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_authn.api.helper import RegistrationFieldsContext +from openedx.core.djangoapps.user_authn.serializers import MFEContextSerializer from openedx.core.djangoapps.user_authn.views.utils import get_mfe_context @@ -65,6 +66,7 @@ def get(self, request, **kwargs): # lint-amnesty, pylint: disable=unused-argume context['registration_fields'].update({ 'fields': registration_fields, }) + optional_fields = RegistrationFieldsContext('optional').get_fields() if optional_fields: context['optional_fields'].update({ @@ -74,7 +76,9 @@ def get(self, request, **kwargs): # lint-amnesty, pylint: disable=unused-argume return Response( status=status.HTTP_200_OK, - data=context + data=MFEContextSerializer( + context + ).data ) diff --git a/openedx/core/djangoapps/user_authn/serializers.py b/openedx/core/djangoapps/user_authn/serializers.py new file mode 100644 index 000000000000..9bbed367e6e2 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/serializers.py @@ -0,0 +1,82 @@ +""" +MFE Context API Serializers +""" + +from rest_framework import serializers + + +class ProvidersSerializer(serializers.Serializer): + """ + Providers Serializers + """ + + id = serializers.CharField(allow_null=True) + name = serializers.CharField(allow_null=True) + iconClass = serializers.CharField(allow_null=True) + iconImage = serializers.CharField(allow_null=True) + skipHintedLogin = serializers.BooleanField(default=False) + skipRegistrationForm = serializers.BooleanField(default=False) + loginUrl = serializers.CharField(allow_null=True) + registerUrl = serializers.CharField(allow_null=True) + + +class PipelineUserDetailsSerializer(serializers.Serializer): + """ + Pipeline User Details Serializers + """ + + username = serializers.CharField(allow_null=True) + email = serializers.CharField(allow_null=True) + name = serializers.CharField(source='fullname', allow_null=True) + firstName = serializers.CharField(source='first_name', allow_null=True) + lastName = serializers.CharField(source='last_name', allow_null=True) + + +class ContextDataSerializer(serializers.Serializer): + """ + Context Data Serializers + """ + + currentProvider = serializers.CharField(allow_null=True) + platformName = serializers.CharField(allow_null=True) + providers = serializers.ListField( + child=ProvidersSerializer(), + allow_null=True + ) + secondaryProviders = serializers.ListField( + child=ProvidersSerializer(), + allow_null=True + ) + finishAuthUrl = serializers.CharField(allow_null=True) + errorMessage = serializers.CharField(allow_null=True) + registerFormSubmitButtonText = serializers.CharField(allow_null=True) + autoSubmitRegForm = serializers.BooleanField(default=False) + syncLearnerProfileData = serializers.BooleanField(default=False) + countryCode = serializers.CharField(allow_null=True) + pipelineUserDetails = serializers.SerializerMethodField() + + def get_pipelineUserDetails(self, obj): + if obj.get('pipeline_user_details'): + return PipelineUserDetailsSerializer(obj.get('pipeline_user_details')).data + return {} + + +class MFEContextSerializer(serializers.Serializer): + """ + Serializer class to convert the keys of MFE Context Response dict object to camelCase format. + """ + + contextData = ContextDataSerializer( + source='context_data', + default={} + ) + registrationFields = serializers.DictField( + source='registration_fields', + default={} + ) + optionalFields = serializers.DictField( + source='optional_fields', + default={ + 'extended_profile': [] + } + ) From 6f9fc234fcd94ca09da7c5c91376e8fb28ae9862 Mon Sep 17 00:00:00 2001 From: Dmytro <98233552+DmytroAlipov@users.noreply.github.com> Date: Thu, 24 Aug 2023 15:10:12 +0300 Subject: [PATCH 2/2] fix: TypeError during student.send_activation_email task (#32743) --- openedx/core/djangoapps/user_authn/tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/tasks.py b/openedx/core/djangoapps/user_authn/tasks.py index 27ddfe466e3b..9c2c4ef1098c 100644 --- a/openedx/core/djangoapps/user_authn/tasks.py +++ b/openedx/core/djangoapps/user_authn/tasks.py @@ -45,7 +45,7 @@ def check_pwned_password_and_send_track_event(user_id, password, internal_user=F @shared_task(bind=True, default_retry_delay=30, max_retries=2) @set_code_owner_attribute -def send_activation_email(self, msg_string, from_address=None): +def send_activation_email(self, msg_string, from_address=None, site_id=None): """ Sending an activation email to the user. """ @@ -62,7 +62,7 @@ def send_activation_email(self, msg_string, from_address=None): dest_addr = msg.recipient.email_address - site = Site.objects.get_current() + site = Site.objects.get(id=site_id) if site_id else Site.objects.get_current() user = User.objects.get(id=msg.recipient.lms_user_id) try: