diff --git a/.gitignore b/.gitignore
index dd36223bc2..62ccda9979 100644
--- a/.gitignore
+++ b/.gitignore
@@ -90,5 +90,8 @@ enterprise/static/enterprise/bundles/*.js
# Virtual environments
venv/
+# pyenv
+.python-version
+
# TODO: When we move to be a service, ignore this too.
#enterprise/static/enterprise/bundles/
diff --git a/.python-version b/.python-version
deleted file mode 100644
index 8bed2f106e..0000000000
--- a/.python-version
+++ /dev/null
@@ -1 +0,0 @@
-3.8.12/envs/venv
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 9f453e6582..60b2dd8d3e 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -16,6 +16,11 @@ Change Log
Unreleased
----------
+[4.9.0]
+--------
+
+feat: add "Setup Auth org id" action for Enterprise Customers (ENT-8169)
+
[4.8.18]
--------
diff --git a/enterprise/__init__.py b/enterprise/__init__.py
index 6c2a61ee09..901407901d 100644
--- a/enterprise/__init__.py
+++ b/enterprise/__init__.py
@@ -2,4 +2,4 @@
Your project description goes here.
"""
-__version__ = "4.8.18"
+__version__ = "4.9.0"
diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py
index 665745c1d0..3182c91968 100644
--- a/enterprise/admin/__init__.py
+++ b/enterprise/admin/__init__.py
@@ -36,6 +36,7 @@
CatalogQueryPreviewView,
EnterpriseCustomerManageLearnerDataSharingConsentView,
EnterpriseCustomerManageLearnersView,
+ EnterpriseCustomerSetupAuthOrgIDView,
EnterpriseCustomerTransmitCoursesView,
TemplatePreviewView,
)
@@ -45,6 +46,7 @@
discovery_query_url,
get_all_field_names,
get_default_catalog_content_filter,
+ get_sso_orchestrator_configure_edx_oauth_path,
localized_utcnow,
)
@@ -233,7 +235,29 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin):
export_as_csv_action('CSV Export', fields=EXPORT_AS_CSV_FIELDS),
]
- change_actions = ('manage_learners', 'manage_learners_data_sharing_consent', 'transmit_courses_metadata')
+ change_actions = (
+ 'setup_auth_org_id',
+ 'manage_learners',
+ 'manage_learners_data_sharing_consent',
+ 'transmit_courses_metadata',
+ )
+
+ def get_change_actions(self, *args, **kwargs):
+ """
+ Buttons that appear at the top of the "Change Enterprise Customer" page.
+
+ Due to a known deficiency in the upstream django_object_actions library, we must STILL define change_actions
+ above with all possible values.
+ """
+ change_actions = (
+ 'manage_learners',
+ 'manage_learners_data_sharing_consent',
+ 'transmit_courses_metadata',
+ )
+ # Add the "Setup Auth org id" button only if it is configured.
+ if get_sso_orchestrator_configure_edx_oauth_path():
+ change_actions = ('setup_auth_org_id',) + change_actions
+ return change_actions
form = EnterpriseCustomerAdminForm
@@ -357,6 +381,19 @@ def transmit_courses_metadata(self, request, obj):
transmit_courses_metadata.label = 'Transmit Courses Metadata'
+ @admin.action(
+ description='Setup auth_org_id for this Enterprise Customer'
+ )
+ def setup_auth_org_id(self, request, obj):
+ """
+ Object tool handler method - redirects to `Setup Auth org id` view.
+ """
+ # url names coming from get_urls are prefixed with 'admin' namespace
+ setup_auth_org_id_url = reverse('admin:' + UrlNames.SETUP_AUTH_ORG_ID, args=(obj.uuid,))
+ return HttpResponseRedirect(setup_auth_org_id_url)
+
+ setup_auth_org_id.label = 'Setup Auth org id'
+
def get_urls(self):
"""
Returns the additional urls used by the custom object tools.
@@ -365,18 +402,23 @@ def get_urls(self):
re_path(
r"^([^/]+)/manage_learners$",
self.admin_site.admin_view(EnterpriseCustomerManageLearnersView.as_view()),
- name=UrlNames.MANAGE_LEARNERS
+ name=UrlNames.MANAGE_LEARNERS,
),
re_path(
r"^([^/]+)/clear_learners_data_sharing_consent",
self.admin_site.admin_view(EnterpriseCustomerManageLearnerDataSharingConsentView.as_view()),
- name=UrlNames.MANAGE_LEARNERS_DSC
+ name=UrlNames.MANAGE_LEARNERS_DSC,
),
re_path(
r"^([^/]+)/transmit_courses_metadata",
self.admin_site.admin_view(EnterpriseCustomerTransmitCoursesView.as_view()),
- name=UrlNames.TRANSMIT_COURSES_METADATA
- )
+ name=UrlNames.TRANSMIT_COURSES_METADATA,
+ ),
+ re_path(
+ r"^([^/]+)/setup_auth_org_id",
+ self.admin_site.admin_view(EnterpriseCustomerSetupAuthOrgIDView.as_view()),
+ name=UrlNames.SETUP_AUTH_ORG_ID,
+ ),
]
return customer_urls + super().get_urls()
diff --git a/enterprise/admin/utils.py b/enterprise/admin/utils.py
index e8ff946f18..f8d3011c3f 100644
--- a/enterprise/admin/utils.py
+++ b/enterprise/admin/utils.py
@@ -25,6 +25,7 @@ class UrlNames:
MANAGE_LEARNERS = URL_PREFIX + "manage_learners"
MANAGE_LEARNERS_DSC = URL_PREFIX + "manage_learners_data_sharing_consent"
TRANSMIT_COURSES_METADATA = URL_PREFIX + "transmit_courses_metadata"
+ SETUP_AUTH_ORG_ID = URL_PREFIX + "setup_auth_org_id"
PREVIEW_EMAIL_TEMPLATE = URL_PREFIX + "preview_email_template"
PREVIEW_QUERY_RESULT = URL_PREFIX + "preview_query_result"
diff --git a/enterprise/admin/views.py b/enterprise/admin/views.py
index da997683b5..9addac150f 100644
--- a/enterprise/admin/views.py
+++ b/enterprise/admin/views.py
@@ -15,7 +15,7 @@
from django.core.management import call_command
from django.db import transaction
from django.db.models import Q
-from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
+from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, HttpResponseServerError, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils.translation import gettext as _
@@ -36,6 +36,7 @@
)
from enterprise.api_client.discovery import get_course_catalog_api_service_client
from enterprise.api_client.ecommerce import EcommerceApiClient
+from enterprise.api_client.sso_orchestrator import EnterpriseSSOOrchestratorApiClient, SsoOrchestratorClientError
from enterprise.constants import PAGE_SIZE
from enterprise.errors import LinkUserToEnterpriseError
from enterprise.models import (
@@ -50,6 +51,7 @@
delete_data_sharing_consent,
enroll_users_in_course,
get_ecommerce_worker_user,
+ get_sso_orchestrator_configure_edx_oauth_path,
validate_course_exists_for_enterprise,
validate_email_to_link,
)
@@ -174,7 +176,8 @@ def get_form_view(self, request, customer_uuid, additional_context=None):
render the form with appropriate context.
"""
context = self._build_context(request, customer_uuid)
- context.update(additional_context)
+ if additional_context:
+ context.update(additional_context)
return render(request, self.template, context)
@@ -895,3 +898,71 @@ def delete(self, request, customer_uuid):
return HttpResponse(message, content_type="application/json", status=404)
return JsonResponse({})
+
+
+class EnterpriseCustomerSetupAuthOrgIDView(BaseEnterpriseCustomerView):
+ """
+ Setup Auth org id View.
+
+ This action will configure SSO to GetSmarter using edX credentials via Auth0.
+ """
+ template = 'enterprise/admin/setup_auth_org_id.html'
+
+ def get(self, request, customer_uuid):
+ """
+ Handle GET request - render "Setup Auth org id" form.
+
+ Arguments:
+ request (django.http.request.HttpRequest): Request instance
+ customer_uuid (str): Enterprise Customer UUID
+
+ Returns:
+ django.http.response.HttpResponse: HttpResponse
+ """
+ if not get_sso_orchestrator_configure_edx_oauth_path():
+ return HttpResponseForbidden(
+ "The ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH setting was not configured."
+ )
+ return self.get_form_view(request, customer_uuid)
+
+ def post(self, request, customer_uuid):
+ """
+ Handle POST request - handle form submissions.
+
+ Arguments:
+ request (django.http.request.HttpRequest): Request instance
+ customer_uuid (str): Enterprise Customer UUID
+ """
+ if not get_sso_orchestrator_configure_edx_oauth_path():
+ return HttpResponseForbidden(
+ "The ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH setting was not configured."
+ )
+
+ enterprise_customer = EnterpriseCustomer.objects.get(uuid=customer_uuid)
+
+ # Call the configure-edx-oauth endpoint on the enterprise-sso-orchestrator service to obtain an orgId.
+ # This will raise SsoOrchestratorClientError if the API request fails.
+ try:
+ auth_org_id = EnterpriseSSOOrchestratorApiClient().configure_edx_oauth(enterprise_customer)
+ except SsoOrchestratorClientError as exc:
+ error_msg = (
+ f"Error configuring edx oauth for enterprise customer {enterprise_customer.name}"
+ f"<{enterprise_customer.uuid}>: {exc}"
+ )
+ LOG.exception(error_msg)
+ return HttpResponseServerError(error_msg)
+
+ if auth_org_id:
+ enterprise_customer.auth_org_id = auth_org_id
+ enterprise_customer.save()
+ messages.success(request, _('Successfully written the "Auth org id" field for this enterprise customer.'))
+ return HttpResponseRedirect(reverse("admin:" + UrlNames.SETUP_AUTH_ORG_ID, args=(customer_uuid,)))
+ else:
+ # Annoyingly, there's still the remote possibility that the request succeeded but we failed to retrieve the
+ # auth_org_id. This might be due to a regression in the API response schema.
+ error_msg = (
+ f"Error configuring edx oauth for enterprise customer {enterprise_customer.name}"
+ f"<{enterprise_customer.uuid}>: Missing orgId."
+ )
+ LOG.exception(error_msg)
+ return HttpResponseServerError(error_msg)
diff --git a/enterprise/api_client/sso_orchestrator.py b/enterprise/api_client/sso_orchestrator.py
index 693987aa2c..28fe70f640 100644
--- a/enterprise/api_client/sso_orchestrator.py
+++ b/enterprise/api_client/sso_orchestrator.py
@@ -16,6 +16,7 @@
get_sso_orchestrator_api_base_url,
get_sso_orchestrator_basic_auth_password,
get_sso_orchestrator_basic_auth_username,
+ get_sso_orchestrator_configure_edx_oauth_path,
get_sso_orchestrator_configure_path,
)
@@ -67,6 +68,14 @@ def _get_orchestrator_configure_url(self):
# probably want config value validated for this
return urljoin(self.base_url, get_sso_orchestrator_configure_path())
+ def _get_orchestrator_configure_edx_oauth_url(self):
+ """
+ get the configure-edx-oauth url for the SSO Orchestrator API
+ """
+ if path := get_sso_orchestrator_configure_edx_oauth_path():
+ return urljoin(self.base_url, path)
+ return None
+
def _create_auth_header(self):
"""
create the basic auth header for requests to the SSO Orchestrator API
@@ -93,7 +102,7 @@ def _create_session(self):
def _post(self, url, data=None):
"""
- make a GET request to the SSO Orchestrator API
+ make a POST request to the SSO Orchestrator API
"""
self._create_session()
response = self.session.post(url, json=data, auth=self._create_auth_header())
@@ -133,3 +142,24 @@ def configure_sso_orchestration_record(
response = self._post(self._get_orchestrator_configure_url(), data=request_data)
return response.get('samlServiceProviderInformation', {}).get('spMetadataUrl', {})
+
+ def configure_edx_oauth(self, enterprise_customer):
+ """
+ Configure SSO to GetSmarter using edX credentials via Auth0.
+
+ Args:
+ enterprise_customer (EnterpriseCustomer): The enterprise customer for which to configure edX OAuth.
+
+ Returns:
+ str: Auth0 Organization ID.
+
+ Raises:
+ SsoOrchestratorClientError: If the request to the SSO Orchestrator API failed.
+ """
+ request_data = {
+ 'enterpriseName': enterprise_customer.name,
+ 'enterpriseSlug': enterprise_customer.slug,
+ 'enterpriseUuid': str(enterprise_customer.uuid),
+ }
+ response = self._post(self._get_orchestrator_configure_edx_oauth_url(), data=request_data)
+ return response.get('orgId', None)
diff --git a/enterprise/settings/test.py b/enterprise/settings/test.py
index 20c1490ccd..e95dcfad49 100644
--- a/enterprise/settings/test.py
+++ b/enterprise/settings/test.py
@@ -363,3 +363,4 @@ def root(*args):
ENTERPRISE_SSO_ORCHESTRATOR_WORKER_PASSWORD = 'password'
ENTERPRISE_SSO_ORCHESTRATOR_BASE_URL = 'https://foobar.com'
ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH = 'configure'
+ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH = 'configure-edx-oauth'
diff --git a/enterprise/templates/enterprise/admin/setup_auth_org_id.html b/enterprise/templates/enterprise/admin/setup_auth_org_id.html
new file mode 100644
index 0000000000..3f9dbadc6a
--- /dev/null
+++ b/enterprise/templates/enterprise/admin/setup_auth_org_id.html
@@ -0,0 +1,59 @@
+{% extends "admin/base_site.html" %}
+{% load i18n static admin_urls %}
+
+{% block breadcrumbs %}
+
+{% endblock %}
+
+{% block content %}
+
+{% endblock %}
+
+{% block footer %}
+ {{ block.super }}
+{% endblock %}
diff --git a/enterprise/utils.py b/enterprise/utils.py
index 291f7d139b..631eeefd48 100644
--- a/enterprise/utils.py
+++ b/enterprise/utils.py
@@ -1565,6 +1565,13 @@ def get_sso_orchestrator_configure_path():
return settings.ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH
+def get_sso_orchestrator_configure_edx_oauth_path():
+ """
+ Return the SSO orchestrator configure-edx-oauth endpoint path, or None if it is not defined.
+ """
+ return getattr(settings, "ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH", None)
+
+
def get_enterprise_worker_user():
"""
Return the user object of enterprise worker user.
diff --git a/tests/test_admin/test_view.py b/tests/test_admin/test_view.py
index 87288b60d3..ca2602881b 100644
--- a/tests/test_admin/test_view.py
+++ b/tests/test_admin/test_view.py
@@ -33,6 +33,7 @@
TransmitEnterpriseCoursesForm,
)
from enterprise.admin.utils import ValidationMessages
+from enterprise.api_client.sso_orchestrator import SsoOrchestratorClientError
from enterprise.constants import PAGE_SIZE
from enterprise.models import (
EnrollmentNotificationEmailTemplate,
@@ -2078,3 +2079,92 @@ def test_post_validation_errors(self):
)
]
}
+
+
+class BaseTestEnterpriseCustomerSetupAuthOrgIDView(BaseEnterpriseCustomerView):
+ """
+ Common functionality for EnterpriseCustomerTransmitCoursesView tests.
+ """
+
+ def setUp(self):
+ """
+ Test set up
+ """
+ super().setUp()
+ self.enterprise_customer.auth_org_id = None
+ self.enterprise_customer.save()
+ self.view_url = reverse(
+ 'admin:' + enterprise_admin.utils.UrlNames.SETUP_AUTH_ORG_ID,
+ args=(self.enterprise_customer.uuid,)
+ )
+
+
+@ddt.ddt
+@mark.django_db
+@override_settings(ROOT_URLCONF='test_utils.admin_urls')
+class TestEnterpriseCustomerSetupAuthOrgIDViewGet(BaseTestEnterpriseCustomerSetupAuthOrgIDView):
+ """
+ Tests for EnterpriseCustomerSetupAuthOrgIDView GET endpoint.
+ """
+
+ def _test_get_response(self, response):
+ """
+ Test view GET response for common parts.
+ """
+ assert response.status_code == 200
+ self._test_common_context(response.context)
+ assert response.context['enterprise_customer'] == self.enterprise_customer
+
+ def test_get_not_logged_in(self):
+ response = self.client.get(self.view_url)
+ assert response.status_code == 302
+
+ def test_get_links(self):
+ self._login()
+
+ response = self.client.get(self.view_url)
+ self._test_get_response(response)
+
+
+@ddt.ddt
+@mark.django_db
+@override_settings(ROOT_URLCONF='test_utils.admin_urls')
+class TestEnterpriseCustomerSetupAuthOrgIDViewPost(BaseTestEnterpriseCustomerSetupAuthOrgIDView):
+ """
+ Tests for EnterpriseCustomerSetupAuthOrgIDView POST endpoint.
+ """
+
+ def test_post_not_logged_in(self):
+ response = self.client.post(self.view_url, data={})
+ assert response.status_code == 302
+
+ @mock.patch('enterprise.api_client.sso_orchestrator.EnterpriseSSOOrchestratorApiClient.configure_edx_oauth')
+ def test_post_happy_path(self, mock_configure_edx_oauth):
+ fake_org_id = 'foobar'
+ mock_configure_edx_oauth.return_value = fake_org_id
+ self._login()
+ response = self.client.post(self.view_url)
+ mock_configure_edx_oauth.assert_called_once_with(self.enterprise_customer)
+ self.enterprise_customer.refresh_from_db()
+ assert self.enterprise_customer.auth_org_id == fake_org_id
+
+ # Now check that the redirect is correct and that the success message is set.
+ self.assertRedirects(response, self.view_url, fetch_redirect_response=False)
+ get_response = self.client.get(self.view_url)
+ actual_messages = {
+ (m.level, m.message) for m in get_response.context['messages']
+ }
+ expected_messages = {
+ (messages.SUCCESS, 'Successfully written the "Auth org id" field for this enterprise customer.'),
+ }
+ assert actual_messages == expected_messages
+
+ @mock.patch('enterprise.api_client.sso_orchestrator.EnterpriseSSOOrchestratorApiClient.configure_edx_oauth')
+ def test_post_api_raises_error(self, mock_configure_edx_oauth):
+ mock_configure_edx_oauth.side_effect = SsoOrchestratorClientError('foobar')
+ self._login()
+ response = self.client.post(self.view_url)
+ assert response.status_code == 500
+ mock_configure_edx_oauth.assert_called_once_with(self.enterprise_customer)
+ self.enterprise_customer.refresh_from_db()
+ assert self.enterprise_customer.auth_org_id is None
diff --git a/tests/test_enterprise/api_client/test_sso_orchestrator.py b/tests/test_enterprise/api_client/test_sso_orchestrator.py
index 683fe2fec6..02dbb39be5 100644
--- a/tests/test_enterprise/api_client/test_sso_orchestrator.py
+++ b/tests/test_enterprise/api_client/test_sso_orchestrator.py
@@ -11,12 +11,24 @@
from django.conf import settings
from enterprise.api_client import sso_orchestrator
-from enterprise.utils import get_sso_orchestrator_api_base_url, get_sso_orchestrator_configure_path
+from enterprise.utils import (
+ get_sso_orchestrator_api_base_url,
+ get_sso_orchestrator_configure_edx_oauth_path,
+ get_sso_orchestrator_configure_path,
+)
+from test_utils.factories import EnterpriseCustomerFactory
TEST_ENTERPRISE_ID = '1840e1dc-59cf-4a78-82c5-c5bbc0b5df0f'
TEST_ENTERPRISE_SSO_CONFIG_UUID = uuid4()
TEST_ENTERPRISE_NAME = 'Test Enterprise'
-SSO_ORCHESTRATOR_CONFIGURE_URL = urljoin(get_sso_orchestrator_api_base_url(), get_sso_orchestrator_configure_path())
+SSO_ORCHESTRATOR_CONFIGURE_URL = urljoin(
+ get_sso_orchestrator_api_base_url(),
+ get_sso_orchestrator_configure_path()
+)
+SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_URL = urljoin(
+ get_sso_orchestrator_api_base_url(),
+ get_sso_orchestrator_configure_edx_oauth_path()
+)
@responses.activate
@@ -51,3 +63,29 @@ def test_post_sso_configuration():
'name': TEST_ENTERPRISE_NAME,
'slug': TEST_ENTERPRISE_NAME
}
+
+
+@responses.activate
+def test_configure_edx_oauth():
+ """
+ Test the configure_edx_oauth method.
+ """
+ fake_enterprise_customer = EnterpriseCustomerFactory.stub()
+ fake_org_id = 'foobar'
+ responses.add(
+ responses.POST,
+ SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_URL,
+ json={'orgId': fake_org_id, 'status': 200},
+ )
+ client = sso_orchestrator.EnterpriseSSOOrchestratorApiClient()
+
+ # Call the method under test:
+ actual_response = client.configure_edx_oauth(enterprise_customer=fake_enterprise_customer)
+
+ assert actual_response == fake_org_id
+ responses.assert_call_count(count=1, url=SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_URL)
+
+ sent_body_params = json.loads(responses.calls[0].request.body)
+ assert sent_body_params['enterpriseName'] == fake_enterprise_customer.name
+ assert sent_body_params['enterpriseSlug'] == fake_enterprise_customer.slug
+ assert sent_body_params['enterpriseUuid'] == str(fake_enterprise_customer.uuid)