Skip to content

Commit

Permalink
feat: basics of business logic for customer provisioning
Browse files Browse the repository at this point in the history
  • Loading branch information
iloveagent57 committed Feb 24, 2025
1 parent a951ee9 commit 79b67d3
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 17 deletions.
39 changes: 28 additions & 11 deletions enterprise_access/apps/api/v1/tests/test_provisioning_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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": {
Expand All @@ -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=['[email protected]'],
)
52 changes: 50 additions & 2 deletions enterprise_access/apps/api/v1/views/provisioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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,
)
35 changes: 31 additions & 4 deletions enterprise_access/apps/api_client/lms_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
Expand All @@ -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

Expand Down
62 changes: 62 additions & 0 deletions enterprise_access/apps/api_client/tests/test_lms_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': '[email protected]',
}]
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):
Expand Down Expand Up @@ -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):
"""
Expand Down
Empty file.
64 changes: 64 additions & 0 deletions enterprise_access/apps/provisioning/api.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions enterprise_access/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 79b67d3

Please sign in to comment.