From 79b67d355f6fbe9558b4ae9efff56d8c88afb058 Mon Sep 17 00:00:00 2001 From: Alexander Dusenbery Date: Thu, 20 Feb 2025 16:22:38 -0500 Subject: [PATCH] feat: basics of business logic for customer provisioning --- .../api/v1/tests/test_provisioning_views.py | 39 +++++++---- .../apps/api/v1/views/provisioning.py | 52 ++++++++++++++- .../apps/api_client/lms_client.py | 35 ++++++++-- .../apps/api_client/tests/test_lms_client.py | 62 ++++++++++++++++++ .../apps/provisioning/__init__.py | 0 enterprise_access/apps/provisioning/api.py | 64 +++++++++++++++++++ enterprise_access/settings/base.py | 1 + 7 files changed, 236 insertions(+), 17 deletions(-) create mode 100644 enterprise_access/apps/provisioning/__init__.py create mode 100644 enterprise_access/apps/provisioning/api.py diff --git a/enterprise_access/apps/api/v1/tests/test_provisioning_views.py b/enterprise_access/apps/api/v1/tests/test_provisioning_views.py index 21d6689a..a9ca26b7 100644 --- a/enterprise_access/apps/api/v1/tests/test_provisioning_views.py +++ b/enterprise_access/apps/api/v1/tests/test_provisioning_views.py @@ -2,6 +2,7 @@ Tests for the provisioning views. """ import uuid +from unittest import mock import ddt from edx_rbac.constants import ALL_ACCESS_CONTEXT @@ -43,11 +44,6 @@ class TestProvisioningAuth(APITest): {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': ALL_ACCESS_CONTEXT}, status.HTTP_403_FORBIDDEN, ), - # Even operators can't provision - ( - {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': ALL_ACCESS_CONTEXT}, - status.HTTP_403_FORBIDDEN, - ), # No JWT based auth, no soup for you. ( None, @@ -66,15 +62,26 @@ def test_provisioning_create_view_forbidden(self, role_context_dict, expected_re response = self.client.post(PROVISIONING_CREATE_ENDPOINT) assert response.status_code == expected_response_code - def test_provisioning_create_allowed_for_provisioning_admins(self): + @ddt.data( + ( + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': ALL_ACCESS_CONTEXT}, + status.HTTP_201_CREATED, + ), + ( + {'system_wide_role': SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, 'context': ALL_ACCESS_CONTEXT}, + status.HTTP_201_CREATED, + ), + ) + @ddt.unpack + @mock.patch('enterprise_access.apps.api.v1.views.provisioning.provisioning_api') + def test_provisioning_create_allowed_for_provisioning_admins( + self, role_context_dict, expected_response_code, mock_provisioning_api, + ): """ Tests that we get expected 200 response for the provisioning create view when the requesting user has the correct system role and provides a valid request payload. """ - self.set_jwt_cookie([{ - 'system_wide_role': SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, - 'context': ALL_ACCESS_CONTEXT, - }]) + self.set_jwt_cookie([role_context_dict]) request_payload = { "enterprise_customer": { @@ -89,4 +96,14 @@ def test_provisioning_create_allowed_for_provisioning_admins(self): ], } response = self.client.post(PROVISIONING_CREATE_ENDPOINT, data=request_payload) - assert response.status_code == status.HTTP_201_CREATED + assert response.status_code == expected_response_code + + mock_provisioning_api.get_or_create_enterprise_customer.assert_called_once_with( + **request_payload['enterprise_customer'], + ) + + created_customer = mock_provisioning_api.get_or_create_enterprise_customer.return_value + mock_provisioning_api.get_or_create_enterprise_admin_users.assert_called_once_with( + enterprise_customer_uuid=created_customer['uuid'], + user_emails=['test-admin@example.com'], + ) diff --git a/enterprise_access/apps/api/v1/views/provisioning.py b/enterprise_access/apps/api/v1/views/provisioning.py index df0f182e..daf37c64 100644 --- a/enterprise_access/apps/api/v1/views/provisioning.py +++ b/enterprise_access/apps/api/v1/views/provisioning.py @@ -3,20 +3,31 @@ """ import logging +import requests from drf_spectacular.utils import extend_schema from edx_rbac.mixins import PermissionRequiredMixin from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from rest_framework import generics, permissions, status +from rest_framework import exceptions, generics, permissions, status from rest_framework.response import Response from enterprise_access.apps.api import serializers from enterprise_access.apps.core import constants +from enterprise_access.apps.provisioning import api as provisioning_api logger = logging.getLogger(__name__) PROVISIONING_API_TAG = 'Provisioning' +class ProvisioningException(exceptions.APIException): + """ + General provisioning-related API exception. + """ + status_code = 422 + default_detail = 'Could not execute this provisioning request' + default_code = 'provisioning_error' + + @extend_schema( tags=[PROVISIONING_API_TAG], summary='Create a new provisioning request.', @@ -37,4 +48,41 @@ class ProvisioningCreateView(PermissionRequiredMixin, generics.CreateAPIView): def create(self, request, *args, **kwargs): request_serializer = serializers.ProvisioningRequestSerializer(data=request.data) request_serializer.is_valid(raise_exception=True) - return Response('ack', status=status.HTTP_201_CREATED) + + customer_request_data = request_serializer.validated_data['enterprise_customer'] + try: + created_customer = provisioning_api.get_or_create_enterprise_customer( + name=customer_request_data['name'], + country=customer_request_data['country'], + slug=customer_request_data['slug'], + ) + except requests.exceptions.HTTPError as exc: + raise ProvisioningException( + detail=f'Error get/creating customer record: {exc}', + code='customer_provisioning_error', + ) from exc + + admin_emails = [ + record.get('user_email') + for record in request_serializer.validated_data['pending_admins'] + ] + + try: + customer_admins = provisioning_api.get_or_create_enterprise_admin_users( + enterprise_customer_uuid=created_customer['uuid'], + user_emails=admin_emails, + ) + except requests.exceptions.HTTPError as exc: + raise ProvisioningException( + detail=f'Error get/creating admin records: {exc}', + code='admin_provisioning_error', + ) from exc + + response_serializer = serializers.ProvisioningResponseSerializer({ + 'enterprise_customer': created_customer, + 'pending_admins': customer_admins, + }) + return Response( + response_serializer.data, + status=status.HTTP_201_CREATED, + ) diff --git a/enterprise_access/apps/api_client/lms_client.py b/enterprise_access/apps/api_client/lms_client.py index cfbf5e22..0653e9f3 100755 --- a/enterprise_access/apps/api_client/lms_client.py +++ b/enterprise_access/apps/api_client/lms_client.py @@ -196,6 +196,33 @@ def get_enterprise_admin_users(self, enterprise_customer_uuid): return results + def get_enterprise_pending_admin_users(self, enterprise_customer_uuid): + """ + Gets all pending enterprise admin records for the given customer uuid. + + Arguments: + enterprise_customer_uuid (UUID): UUID of the enterprise customer. + Returns: + List of dictionaries of pending admin users. + """ + response = self.client.get( + self.pending_enterprise_admin_endpoint + f'?enterprise_customer={enterprise_customer_uuid}', + timeout=settings.LMS_CLIENT_TIMEOUT, + ) + try: + response.raise_for_status() + logger.info( + 'Fetched pending admin records for customer %s', enterprise_customer_uuid, + ) + payload = response.json() + return payload.get('results', []) + except requests.exceptions.HTTPError: + logger.exception( + 'Failed to fetch pending admin record for customer %s: %s', + enterprise_customer_uuid, response.content.decode() + ) + raise + def create_enterprise_admin_user(self, enterprise_customer_uuid, user_email): """ Creates a new enterprise pending admin record. @@ -210,12 +237,12 @@ def create_enterprise_admin_user(self, enterprise_customer_uuid, user_email): 'enterprise_customer': enterprise_customer_uuid, 'user_email': user_email, } - try: - response = self.client.post( + response = self.client.post( self.pending_enterprise_admin_endpoint, json=payload, timeout=settings.LMS_CLIENT_TIMEOUT, ) + try: response.raise_for_status() logger.info( 'Successfully created pending admin record for customer %s, email %s', @@ -225,8 +252,8 @@ def create_enterprise_admin_user(self, enterprise_customer_uuid, user_email): return payload except requests.exceptions.HTTPError: logger.exception( - 'Failed to create pending admin record for customer %s, email %s', - enterprise_customer_uuid, user_email, + 'Failed to create pending admin record for customer %s, email %s: %s', + enterprise_customer_uuid, user_email, response.content.decode() ) raise diff --git a/enterprise_access/apps/api_client/tests/test_lms_client.py b/enterprise_access/apps/api_client/tests/test_lms_client.py index d5584d63..7f990a0e 100644 --- a/enterprise_access/apps/api_client/tests/test_lms_client.py +++ b/enterprise_access/apps/api_client/tests/test_lms_client.py @@ -216,6 +216,43 @@ def test_create_enterprise_customer_data(self, mock_oauth_client, mock_json): timeout=settings.LMS_CLIENT_TIMEOUT, ) + @mock.patch('requests.Response.json') + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient') + def test_get_enterprise_pending_admin_users(self, mock_oauth_client, mock_json): + """ + Test that we can use the LmsApiClient to fetch existing pending admin records. + """ + customer_uuid = str(uuid4()) + + mock_response_payload_results = [{ + 'id': 1, + 'enterprise_customer': customer_uuid, + 'user_email': 'test-existing-admin@example.com', + }] + mock_response_payload = { + 'count': 1, + 'results': mock_response_payload_results, + } + mock_json.return_value = mock_response_payload + + mock_get = mock_oauth_client.return_value.get + + mock_get.return_value = requests.Response() + mock_get.return_value.status_code = 200 + + client = LmsApiClient() + response_payload = client.get_enterprise_pending_admin_users(customer_uuid) + + self.assertEqual(response_payload, mock_response_payload_results) + expected_url = ( + 'http://edx-platform.example.com/enterprise/api/v1/pending-enterprise-admin/' + f'?enterprise_customer={customer_uuid}' + ) + mock_get.assert_called_once_with( + expected_url, + timeout=settings.LMS_CLIENT_TIMEOUT, + ) + @mock.patch('requests.Response.json') @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient') def test_create_enterprise_admin_user(self, mock_oauth_client, mock_json): @@ -316,6 +353,31 @@ def test_create_enterprise_admin_error(self, mock_oauth_client): timeout=settings.LMS_CLIENT_TIMEOUT, ) + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient') + def test_get_enterprise_pending_admin_error(self, mock_oauth_client): + """ + Tests that we raise an exception appropriately when listing pending + admin records with the LmsApiClient(). + """ + customer_uuid = str(uuid4()) + mock_get = mock_oauth_client.return_value.get + + mock_get.side_effect = requests.exceptions.HTTPError('whoopsie') + mock_get.return_value.status_code = 400 + + client = LmsApiClient() + with self.assertRaises(requests.exceptions.HTTPError): + client.get_enterprise_pending_admin_users(customer_uuid) + + expected_url = ( + 'http://edx-platform.example.com/enterprise/api/v1/pending-enterprise-admin/' + f'?enterprise_customer={customer_uuid}' + ) + mock_get.assert_called_once_with( + expected_url, + timeout=settings.LMS_CLIENT_TIMEOUT, + ) + @mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient') def test_unlink_users_from_enterprise(self, mock_oauth_client): """ diff --git a/enterprise_access/apps/provisioning/__init__.py b/enterprise_access/apps/provisioning/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/enterprise_access/apps/provisioning/api.py b/enterprise_access/apps/provisioning/api.py new file mode 100644 index 00000000..14e72db0 --- /dev/null +++ b/enterprise_access/apps/provisioning/api.py @@ -0,0 +1,64 @@ +""" +Python API for provisioning operations. +""" +import logging + +from ..api_client.lms_client import LmsApiClient + +logger = logging.getLogger(__name__) + + +def get_or_create_enterprise_customer(*, name, slug, country, **kwargs): + """ + Get or creates an enterprise customer with the provided arguments. + """ + client = LmsApiClient() + existing_customer = client.get_enterprise_customer_data(enterprise_customer_slug=slug) + if existing_customer: + logger.info('Provisioning: enterprise_customer slug %s already exists', slug) + return existing_customer + + created_customer = client.create_enterprise_customer( + name=name, slug=slug, country=country, **kwargs, + ) + logger.info('Provisioning: created enterprise customer with slug %s', slug) + + +def get_or_create_enterprise_admin_users(enterprise_customer_uuid, user_emails): + """ + Creates pending admin records from the given ``user_email`` for the customer + identified by ``enterprise_customer_uuid``. + """ + client = LmsApiClient() + existing_admins = client.get_enterprise_admin_users(enterprise_customer_uuid) + existing_admin_emails = {record['email'] for record in existing_admins} + logger.info( + 'Provisioning: customer %s has existing admin emails %s', + enterprise_customer_uuid, + existing_admin_emails, + ) + + existing_pending_admins = client.get_enterprise_pending_admin_users(enterprise_customer_uuid) + existing_pending_admin_emails = {record['user_email'] for record in existing_pending_admins} + logger.info( + 'Provisioning: customer %s has existing pending admin emails %s', + enterprise_customer_uuid, + existing_pending_admin_emails, + ) + + user_emails_to_create = list( + (set(user_emails) - existing_admin_emails) - existing_pending_admin_emails + ) + + created_admins = [] + for user_email in user_emails_to_create: + created_admins.append( + client.create_enterprise_admin_user(enterprise_customer_uuid, user_email) + ) + logger.info( + 'Provisioning: created admin %s for customer %s', + user_email, + enterprise_customer_uuid, + ) + + return created_admins + existing_pending_admins diff --git a/enterprise_access/settings/base.py b/enterprise_access/settings/base.py index 533f8dfd..2c7c7159 100644 --- a/enterprise_access/settings/base.py +++ b/enterprise_access/settings/base.py @@ -332,6 +332,7 @@ def root(*path_fragments): CONTENT_ASSIGNMENTS_OPERATOR_ROLE, REQUESTS_ADMIN_ROLE, BFF_OPERATOR_ROLE, + PROVISIONING_ADMIN_ROLE, ], SYSTEM_ENTERPRISE_ADMIN_ROLE: [ # enterprise admins only need learner-level access to Subsidy Access Policy APIs since they aren't responsible