diff --git a/enterprise_access/apps/api/filters/__init__.py b/enterprise_access/apps/api/filters/__init__.py index 01bd227a..31b36785 100644 --- a/enterprise_access/apps/api/filters/__init__.py +++ b/enterprise_access/apps/api/filters/__init__.py @@ -2,5 +2,6 @@ Module for filters across all enterprise-access apps. """ from .base import NoFilterOnDetailBackend +from .content_assignments import AssignmentConfigurationFilter from .subsidy_access_policy import SubsidyAccessPolicyFilter from .subsidy_request import SubsidyRequestCustomerConfigurationFilterBackend, SubsidyRequestFilterBackend diff --git a/enterprise_access/apps/api/filters/content_assignments.py b/enterprise_access/apps/api/filters/content_assignments.py new file mode 100644 index 00000000..62edad94 --- /dev/null +++ b/enterprise_access/apps/api/filters/content_assignments.py @@ -0,0 +1,14 @@ +""" +API Filters for resources defined in the ``assignment_policy`` app. +""" +from ...content_assignments.models import AssignmentConfiguration +from .base import HelpfulFilterSet + + +class AssignmentConfigurationFilter(HelpfulFilterSet): + """ + Base filter for AssignmentConfiguration views. + """ + class Meta: + model = AssignmentConfiguration + fields = ['active'] diff --git a/enterprise_access/apps/api/serializers/__init__.py b/enterprise_access/apps/api/serializers/__init__.py index c10d60dd..861da262 100644 --- a/enterprise_access/apps/api/serializers/__init__.py +++ b/enterprise_access/apps/api/serializers/__init__.py @@ -1,6 +1,12 @@ """ API serializers module. """ +from .assignment_configuration import ( + AssignmentConfigurationCreateRequestSerializer, + AssignmentConfigurationDeleteRequestSerializer, + AssignmentConfigurationResponseSerializer, + AssignmentConfigurationUpdateRequestSerializer +) from .subsidy_access_policy import ( SubsidyAccessPolicyCanRedeemElementResponseSerializer, SubsidyAccessPolicyCanRedeemReasonResponseSerializer, diff --git a/enterprise_access/apps/api/serializers/assignment_configuration.py b/enterprise_access/apps/api/serializers/assignment_configuration.py new file mode 100644 index 00000000..f9b3ed22 --- /dev/null +++ b/enterprise_access/apps/api/serializers/assignment_configuration.py @@ -0,0 +1,156 @@ +""" +Serializers for the `AssignmentConfiguration` model. +""" +import logging + +from rest_framework import serializers + +from enterprise_access.apps.content_assignments.models import AssignmentConfiguration +from enterprise_access.apps.subsidy_access_policy.models import SubsidyAccessPolicy + +logger = logging.getLogger(__name__) + + +class AssignmentConfigurationResponseSerializer(serializers.ModelSerializer): + """ + A read-only Serializer for responding to requests for ``AssignmentConfiguration`` records. + """ + # This causes the related SubsidyAccessPolicy to be serialized as a UUID (in the response). + subsidy_access_policy = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta: + model = AssignmentConfiguration + fields = [ + 'uuid', + 'subsidy_access_policy', + 'enterprise_customer_uuid', + 'active', + ] + read_only_fields = fields + + +class AssignmentConfigurationCreateRequestSerializer(serializers.ModelSerializer): + """ + Serializer to validate request data for create() (POST) operations. + """ + # This causes field validation to check for a UUID (in the request) and validates that a SubsidyAccessPolicy + # actually exists with that UUID. + subsidy_access_policy = serializers.PrimaryKeyRelatedField(queryset=SubsidyAccessPolicy.objects.all()) + + class Meta: + model = AssignmentConfiguration + fields = [ + 'uuid', + 'subsidy_access_policy', + 'active', + ] + read_only_fields = [ + 'uuid', + 'active', + ] + extra_kwargs = { + 'uuid': {'read_only': True}, + 'subsidy_access_policy': { + 'allow_null': False, + 'required': True, + }, + 'active': {'read_only': True}, + } + + @property + def calling_view(self): + """ + Return the view that called this serializer. + """ + return self.context['view'] + + def create(self, validated_data): + """ + Get or create or reactivate an AssignmentConfiguration object. + """ + # First, search for any AssignmentConfigs that share the requested SubsidyAccessPolicy, and return that if found + # (activating it if necessary). We will essentially treat the 'subsidy_access_policy' as the idempotency key for + # de-duplication purposes. + existing_subsidy_access_policy = validated_data['subsidy_access_policy'] + found_assignment_config = existing_subsidy_access_policy.assignment_configuration + if found_assignment_config: + if not found_assignment_config.active: + found_assignment_config.active = True + found_assignment_config.save() + self.calling_view.set_assignment_config_created(False) + return found_assignment_config + self.calling_view.set_assignment_config_created(True) + + # Copy the enterprise customer UUID from the SubsidyAccessPolicy into the new AssignmentConfiguration object. + validated_data['enterprise_customer_uuid'] = existing_subsidy_access_policy.enterprise_customer_uuid + + # Actually create the new AssignmentConfiguration. + new_assignment_config = super().create(validated_data) + + # Manually link the new AssignmentConfiguration to the existing SubsidyAccessPolicy. For some reason this + # reverse relationship is not automatically created by virtue of validated_data having the + # 'subsidy_access_policy' key. + existing_subsidy_access_policy.assignment_configuration = new_assignment_config + existing_subsidy_access_policy.save() + + return new_assignment_config + + def to_representation(self, instance): + """ + Once an AssignmentConfiguration has been created, we want to serialize more fields from the instance than are + required in this, the input serializer. + """ + read_serializer = AssignmentConfigurationResponseSerializer(instance) + return read_serializer.data + + +# pylint: disable=abstract-method +class AssignmentConfigurationDeleteRequestSerializer(serializers.Serializer): + """ + Request Serializer for DELETE parameters to an API call to deactivate an AssignmentConfiguration. + + For view: AssignmentConfigurationViewSet.destroy + """ + reason = serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + help_text="Optional description (free form text) for why the AssignmentConfiguration is being deactivated.", + ) + + +class AssignmentConfigurationUpdateRequestSerializer(serializers.ModelSerializer): + """ + Request Serializer for PUT or PATCH requests to update an AssignmentConfiguration. + + For views: AssignmentConfigurationViewSet.update and AssignmentConfigurationViewSet.partial_update. + """ + class Meta: + model = AssignmentConfiguration + fields = ( + 'active', + ) + extra_kwargs = { + 'active': { + 'allow_null': False, + 'required': False, + }, + } + + def validate(self, attrs): + """ + Raises a ValidationError if any field not explicitly declared as a field in this serializer definition is + provided as input. + """ + unknown = sorted(set(self.initial_data) - set(self.fields)) + if unknown: + raise serializers.ValidationError("Field(s) are not updatable: {}".format(", ".join(unknown))) + return attrs + + def to_representation(self, instance): + """ + Once an AssignmentConfiguration has been updated, we want to serialize more fields from the instance than are + required in this, the input serializer. + """ + read_serializer = AssignmentConfigurationResponseSerializer(instance) + return read_serializer.data diff --git a/enterprise_access/apps/api/utils.py b/enterprise_access/apps/api/utils.py index f57477d5..6725038b 100644 --- a/enterprise_access/apps/api/utils.py +++ b/enterprise_access/apps/api/utils.py @@ -6,6 +6,7 @@ from rest_framework.exceptions import ParseError +from enterprise_access.apps.content_assignments.api import get_assignment_configuration from enterprise_access.apps.subsidy_access_policy.api import get_subsidy_access_policy @@ -60,3 +61,14 @@ def get_policy_customer_uuid(policy_uuid): if policy: return str(policy.enterprise_customer_uuid) return None + + +def get_assignment_config_customer_uuid(assignment_configuration_uuid): + """ + Given an AssignmentConfiguration uuid, returns the corresponding, string-ified customer uuid associated with that + policy, if found. + """ + assignment_config = get_assignment_configuration(assignment_configuration_uuid) + if assignment_config: + return str(assignment_config.enterprise_customer_uuid) + return None diff --git a/enterprise_access/apps/api/v1/tests/test_assignment_configuration_views.py b/enterprise_access/apps/api/v1/tests/test_assignment_configuration_views.py new file mode 100644 index 00000000..d2560cfa --- /dev/null +++ b/enterprise_access/apps/api/v1/tests/test_assignment_configuration_views.py @@ -0,0 +1,431 @@ +""" +Tests for AssignmentConfiguration API views. +""" +from uuid import uuid4 + +import ddt +from rest_framework import status +from rest_framework.reverse import reverse + +from enterprise_access.apps.content_assignments.tests.factories import AssignmentConfigurationFactory +from enterprise_access.apps.core.constants import ( + SYSTEM_ENTERPRISE_ADMIN_ROLE, + SYSTEM_ENTERPRISE_LEARNER_ROLE, + SYSTEM_ENTERPRISE_OPERATOR_ROLE +) +from enterprise_access.apps.subsidy_access_policy.tests.factories import AssignedLearnerCreditAccessPolicyFactory +from test_utils import APITest + +ASSIGNMENT_CONFIGURATION_LIST_ENDPOINT = reverse('api:v1:assignment-configurations-list') + +TEST_ENTERPRISE_UUID = uuid4() + + +# pylint: disable=missing-function-docstring +class CRUDViewTestMixin: + """ + Mixin to set some basic state for test classes that cover the AssignmentConfiguration CRUD views. + """ + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.enterprise_uuid = TEST_ENTERPRISE_UUID + other_enterprise_uuid = uuid4() + + # Create a pair of AssignmentConfiguration + SubsidyAccessPolicy for the main test customer. + cls.assignment_configuration_existing = AssignmentConfigurationFactory( + enterprise_customer_uuid=cls.enterprise_uuid, + ) + cls.assigned_learner_credit_policy = AssignedLearnerCreditAccessPolicyFactory( + display_name='An assigned learner credit policy, for the test customer.', + enterprise_customer_uuid=cls.enterprise_uuid, + active=True, + assignment_configuration=cls.assignment_configuration_existing, + ) + + # Create a pair of AssignmentConfiguration + SubsidyAccessPolicy for the "other" customer. + # This is useful for testing that enterprise admins cannot read each other's models. + cls.assignment_configuration_other_customer = AssignmentConfigurationFactory( + enterprise_customer_uuid=other_enterprise_uuid, + ) + cls.assigned_learner_credit_policy_other_customer = AssignedLearnerCreditAccessPolicyFactory( + display_name='An assigned learner credit policy, for a different customer.', + enterprise_customer_uuid=other_enterprise_uuid, + active=True, + assignment_configuration=cls.assignment_configuration_other_customer, + ) + + # Create a standalone policy that is ripe for having an associated AssignmentConfiguration created. + cls.assigned_learner_credit_policy_standalone = AssignedLearnerCreditAccessPolicyFactory( + display_name='A standalone assigned learner credit policy that really wants an AssignmentConfiguration.', + enterprise_customer_uuid=cls.enterprise_uuid, + active=True, + assignment_configuration=None, + ) + + def setUp(self): + super().setUp() + # Start in an unauthenticated state. + self.client.logout() + + +@ddt.ddt +class TestAssignmentConfigurationUnauthorizedCRUD(CRUDViewTestMixin, APITest): + """ + Tests Authentication and Permission checking for AssignmentConfiguration CRUD views. + """ + @ddt.data( + # A role that's not mapped to any feature perms will get you a 403. + ( + {'system_wide_role': 'some-other-role', 'context': str(TEST_ENTERPRISE_UUID)}, + status.HTTP_403_FORBIDDEN, + ), + # A good learner role, AND in the correct context/customer STILL gets you a 403. + # AssignmentConfiguration APIs are inaccessible to all learners. + ( + {'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + status.HTTP_403_FORBIDDEN, + ), + # A good admin role, but in a context/customer we're not aware of, gets you a 403. + ( + {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(uuid4())}, + status.HTTP_403_FORBIDDEN, + ), + # An operator role, but in a context/customer we're not aware of, gets you a 403. + ( + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(uuid4())}, + status.HTTP_403_FORBIDDEN, + ), + # No JWT based auth, no soup for you. + ( + None, + status.HTTP_401_UNAUTHORIZED, + ), + ) + @ddt.unpack + def test_assignment_config_readwrite_views_unauthorized_forbidden(self, role_context_dict, expected_response_code): + """ + Tests that we get expected 40x responses for all of the read OR write views. + """ + # Set the JWT-based auth that we'll use for every request + if role_context_dict: + self.set_jwt_cookie([role_context_dict]) + + detail_kwargs = {'uuid': str(self.assignment_configuration_existing.uuid)} + detail_url = reverse('api:v1:assignment-configurations-detail', kwargs=detail_kwargs) + list_url = reverse('api:v1:assignment-configurations-list') + + # Test views that need CONTENT_ASSIGNMENT_CONFIGURATION_READ_PERMISSION: + + # GET/retrieve endpoint: + response = self.client.get(detail_url) + assert response.status_code == expected_response_code + + # GET/list endpoint: + request_params = {'enterprise_customer_uuid': str(TEST_ENTERPRISE_UUID)} + response = self.client.get(list_url, request_params) + assert response.status_code == expected_response_code + + # Test views that need CONTENT_ASSIGNMENT_CONFIGURATION_WRITE_PERMISSION: + + # POST/create endpoint: + create_payload = {'subsidy_access_policy': str(self.assigned_learner_credit_policy_standalone.uuid)} + response = self.client.post(list_url, data=create_payload) + assert response.status_code == expected_response_code + + # PUT/update endpoint: + response = self.client.put(detail_url, data={'active': True}) + assert response.status_code == expected_response_code + + # PATCH/partial_update endpoint: + response = self.client.patch(detail_url, data={'active': True}) + assert response.status_code == expected_response_code + + # DELETE/destroy endpoint: + response = self.client.delete(detail_url) + assert response.status_code == expected_response_code + + @ddt.data( + # A good admin role, AND in the correct context/customer STILL gets you a 403. + # AssignmentConfiguration write APIs are inaccessible to all enterprise admins. + ( + {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + status.HTTP_403_FORBIDDEN, + ), + ) + @ddt.unpack + def test_assignment_config_write_views_unauthorized_forbidden(self, role_context_dict, expected_response_code): + """ + Tests that we get expected 40x responses for only the write views. + """ + # Set the JWT-based auth that we'll use for every request + if role_context_dict: + self.set_jwt_cookie([role_context_dict]) + + detail_kwargs = {'uuid': str(self.assignment_configuration_existing.uuid)} + detail_url = reverse('api:v1:assignment-configurations-detail', kwargs=detail_kwargs) + list_url = reverse('api:v1:assignment-configurations-list') + + # Test views that need CONTENT_ASSIGNMENT_CONFIGURATION_WRITE_PERMISSION: + + # POST/create endpoint: + create_payload = {'subsidy_access_policy': str(self.assigned_learner_credit_policy_standalone.uuid)} + response = self.client.post(list_url, data=create_payload) + assert response.status_code == expected_response_code + + # PUT/update endpoint: + response = self.client.put(detail_url, data={'active': True}) + assert response.status_code == expected_response_code + + # PATCH/partial_update endpoint: + response = self.client.patch(detail_url, data={'active': True}) + assert response.status_code == expected_response_code + + # DELETE/destroy endpoint: + response = self.client.delete(detail_url) + assert response.status_code == expected_response_code + + +@ddt.ddt +class TestAssignmentConfigurationAuthorizedCRUD(CRUDViewTestMixin, APITest): + """ + Test the AssignmentConfiguration API views while successfully authenticated/authorized. + """ + @ddt.data( + # A good admin role, and with a context matching the main testing customer. + {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + # A good operator role, and with a context matching the main testing customer. + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + ) + def test_retrieve(self, role_context_dict): + """ + Test that the retrieve view returns a 200 response code and the expected results of serialization. + """ + # Set the JWT-based auth that we'll use for every request. + self.set_jwt_cookie([role_context_dict]) + + # Setup and call the retrieve endpoint. + detail_kwargs = {'uuid': str(self.assignment_configuration_existing.uuid)} + detail_url = reverse('api:v1:assignment-configurations-detail', kwargs=detail_kwargs) + response = self.client.get(detail_url) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + 'uuid': str(self.assignment_configuration_existing.uuid), + 'active': True, + 'enterprise_customer_uuid': str(TEST_ENTERPRISE_UUID), + 'subsidy_access_policy': str(self.assigned_learner_credit_policy.uuid), + } + + @ddt.data( + # A good admin role, and with a context matching the main testing customer. + {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + # A good operator role, and with a context matching the main testing customer. + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + ) + def test_list(self, role_context_dict): + """ + Test that the list view returns a 200 response code and the expected (list) results of serialization. It should + also allow system-wide admins and operators. + + This also tests that only AssignmentConfigs of the requested customer are returned. + """ + # Set the JWT-based auth that we'll use for every request. + self.set_jwt_cookie([role_context_dict]) + + # Send a list request for all AssignmentConfigurations for the main test customer. + list_url = reverse('api:v1:assignment-configurations-list') + request_params = {'enterprise_customer_uuid': str(TEST_ENTERPRISE_UUID)} + response = self.client.get(list_url, request_params) + + # Only the AssignmentConfiguration for the main customer is returned, and not that of the other customer. + assert response.json()['count'] == 1 + assert response.json()['results'][0] == { + 'uuid': str(self.assignment_configuration_existing.uuid), + 'active': True, + 'enterprise_customer_uuid': str(TEST_ENTERPRISE_UUID), + 'subsidy_access_policy': str(self.assigned_learner_credit_policy.uuid), + } + + @ddt.data( + { + 'request_payload': {'reason': 'Peer Pressure.'}, + 'expected_change_reason': 'Peer Pressure.', + }, + { + 'request_payload': {'reason': ''}, + 'expected_change_reason': None, + }, + { + 'request_payload': {'reason': None}, + 'expected_change_reason': None, + }, + { + 'request_payload': {}, + 'expected_change_reason': None, + }, + ) + @ddt.unpack + def test_destroy(self, request_payload, expected_change_reason): + """ + Test that the destroy view performs a soft-delete and returns an appropriate response with 200 status code and + the expected results of serialization. Also test that the AssignmentConfiguration is unlinked from the + associated policy. + """ + # Set the JWT-based auth to an operator. + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} + ]) + + # Call the destroy endpoint. + detail_kwargs = {'uuid': str(self.assignment_configuration_existing.uuid)} + detail_url = reverse('api:v1:assignment-configurations-detail', kwargs=detail_kwargs) + response = self.client.delete(detail_url, request_payload) + + assert response.status_code == status.HTTP_200_OK + expected_response = { + 'uuid': str(self.assignment_configuration_existing.uuid), + 'active': False, + 'enterprise_customer_uuid': str(TEST_ENTERPRISE_UUID), + 'subsidy_access_policy': None, + } + assert response.json() == expected_response + + # Check that the latest history record for this AssignmentConfiguration contains the change reason provided via + # the API. + self.assignment_configuration_existing.refresh_from_db() + latest_history_entry = self.assignment_configuration_existing.history.order_by('-history_date').first() + assert latest_history_entry.history_change_reason == expected_change_reason + + # Check that the AssignmentConfiguration is unlinked from the associated policy. + self.assigned_learner_credit_policy.refresh_from_db() + assert self.assigned_learner_credit_policy.assignment_configuration is None + + # Test idempotency of the destroy endpoint. + response = self.client.delete(detail_url, request_payload) + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_response + + @ddt.data(True, False) + def test_update_views(self, is_patch): + """ + Test that the update and partial_update views can modify certain fields of an AssignmentConfiguration record. + """ + # Set the JWT-based auth to an operator. + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} + ]) + + detail_kwargs = {'uuid': str(self.assignment_configuration_existing.uuid)} + detail_url = reverse('api:v1:assignment-configurations-detail', kwargs=detail_kwargs) + + action = self.client.patch if is_patch else self.client.put + # Right now there's nothing really interesting on the model to update. + request_payload = { + 'active': False, + } + response = action(detail_url, data=request_payload) + + assert response.status_code == status.HTTP_200_OK + expected_response = { + 'uuid': str(self.assignment_configuration_existing.uuid), + 'active': False, + 'enterprise_customer_uuid': str(TEST_ENTERPRISE_UUID), + 'subsidy_access_policy': str(self.assigned_learner_credit_policy.uuid), + } + assert response.json() == expected_response + + def test_update_views_fields_disallowed_for_update(self): + """ + Test that the update and partial_update views can NOT modify fields + of a policy record that are not included in the update request serializer fields defintion. + """ + # Set the JWT-based auth to an operator. + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} + ]) + + request_payload = { + 'uuid': str(uuid4()), + 'enterprise_customer_uuid': str(uuid4()), + 'subsidy_access_policy': str(uuid4()), + 'created': '1970-01-01 12:00:00Z', + 'modified': '1970-01-01 12:00:00Z', + 'nonsense_key': 'ship arriving too late to save a drowning witch', + } + + detail_kwargs = {'uuid': str(self.assignment_configuration_existing.uuid)} + detail_url = reverse('api:v1:assignment-configurations-detail', kwargs=detail_kwargs) + + expected_unknown_keys = ", ".join(sorted(request_payload.keys())) + + # Test the PUT view + response = self.client.put(detail_url, data=request_payload) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {'non_field_errors': [f'Field(s) are not updatable: {expected_unknown_keys}']} + + # Test the PATCH view + response = self.client.patch(detail_url, data=request_payload) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {'non_field_errors': [f'Field(s) are not updatable: {expected_unknown_keys}']} + + def test_create(self): + """ + Test that create view happy path. A net-new AsssignmentConfiguration should be created. + """ + # Set the JWT-based auth to an operator. + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} + ]) + + # Send a create request which should create a net-new AssignmentConfiguration. + # Also check that it is linked to the appropriate SubsidyAccessPolicy. + list_url = reverse('api:v1:assignment-configurations-list') + post_payload = {'subsidy_access_policy': str(self.assigned_learner_credit_policy_standalone.uuid)} + response = self.client.post(list_url, post_payload) + assert response.status_code == status.HTTP_201_CREATED + self.assigned_learner_credit_policy_standalone.refresh_from_db() + assert response.json() == { + 'uuid': str(self.assigned_learner_credit_policy_standalone.assignment_configuration.uuid), + 'active': True, + 'enterprise_customer_uuid': str(TEST_ENTERPRISE_UUID), + 'subsidy_access_policy': str(self.assigned_learner_credit_policy_standalone.uuid), + } + + def test_create_idempotent(self): + """ + Test that the create view idempotently returns an existing AsssignmentConfiguration when the requested policy + already has a linked one. + """ + # Set the JWT-based auth to an operator. + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} + ]) + + # Send a create request which should return a pre-existing AssignmentConfiguration. + list_url = reverse('api:v1:assignment-configurations-list') + post_payload = {'subsidy_access_policy': str(self.assigned_learner_credit_policy.uuid)} + response = self.client.post(list_url, post_payload) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + 'uuid': str(self.assignment_configuration_existing.uuid), + 'active': True, + 'enterprise_customer_uuid': str(TEST_ENTERPRISE_UUID), + 'subsidy_access_policy': str(self.assigned_learner_credit_policy.uuid), + } + + def test_create_unauthorized_other_customer(self): + """ + Test that the create view fails when the requested policy belongs to a different customer. + """ + # Set the JWT-based auth to an operator. + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} + ]) + + # Send a create request for a policy belonging to a different customer. This should not be allowed! + list_url = reverse('api:v1:assignment-configurations-list') + post_payload = {'subsidy_access_policy': str(self.assigned_learner_credit_policy_other_customer.uuid)} + response = self.client.post(list_url, post_payload) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/enterprise_access/apps/api/v1/urls.py b/enterprise_access/apps/api/v1/urls.py index 8eb02c2d..e18011f0 100644 --- a/enterprise_access/apps/api/v1/urls.py +++ b/enterprise_access/apps/api/v1/urls.py @@ -14,5 +14,6 @@ router.register("license-requests", views.LicenseRequestViewSet, 'license-requests') router.register("coupon-code-requests", views.CouponCodeRequestViewSet, 'coupon-code-requests') router.register("customer-configurations", views.SubsidyRequestCustomerConfigurationViewSet, 'customer-configurations') +router.register("assignment-configurations", views.AssignmentConfigurationViewSet, 'assignment-configurations') urlpatterns += router.urls diff --git a/enterprise_access/apps/api/v1/views/__init__.py b/enterprise_access/apps/api/v1/views/__init__.py index eee4a0f6..bbd07d9c 100644 --- a/enterprise_access/apps/api/v1/views/__init__.py +++ b/enterprise_access/apps/api/v1/views/__init__.py @@ -2,6 +2,7 @@ Top-level views module for convenience of maintaining existing imports of browse and request AND access policy views. """ +from .assignment_configuration import AssignmentConfigurationViewSet from .browse_and_request import ( CouponCodeRequestViewSet, LicenseRequestViewSet, diff --git a/enterprise_access/apps/api/v1/views/assignment_configuration.py b/enterprise_access/apps/api/v1/views/assignment_configuration.py new file mode 100644 index 00000000..7abe518d --- /dev/null +++ b/enterprise_access/apps/api/v1/views/assignment_configuration.py @@ -0,0 +1,253 @@ +""" +REST API views for the content_assignments app. +""" +import logging + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from edx_rbac.decorators import permission_required +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import authentication, mixins, permissions, status, viewsets +from rest_framework.response import Response + +from enterprise_access.apps.api import filters, serializers, utils +from enterprise_access.apps.content_assignments.models import AssignmentConfiguration +from enterprise_access.apps.core.constants import ( + CONTENT_ASSIGNMENTS_CONFIGURATION_READ_PERMISSION, + CONTENT_ASSIGNMENTS_CONFIGURATION_WRITE_PERMISSION +) + +from .utils import PaginationWithPageCount + +logger = logging.getLogger(__name__) + +CONTENT_ASSIGNMENTS_CONFIGURATION_CRUD_API_TAG = 'Content Assignment Configuration CRUD' + + +def assignment_config_permission_create_fn(request): + """ + Helper to use with @permission_required on create endpoint. + """ + policy_uuid_from_query_params = request.data.get('subsidy_access_policy') + return utils.get_policy_customer_uuid(policy_uuid_from_query_params) + + +def assignment_config_permission_detail_fn(request, *args, uuid=None, **kwargs): + """ + Helper to use with @permission_required on detail-type endpoints (retrieve, update, partial_update, destroy). + + Args: + uuid (str): UUID representing an AssignmentConfiguration object. + """ + return utils.get_assignment_config_customer_uuid(uuid) + + +class AssignmentConfigurationViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + """ + Viewset supporting all CRUD operations on ``AssignmentConfiguration`` records. + """ + permission_classes = (permissions.IsAuthenticated,) + serializer_class = serializers.AssignmentConfigurationResponseSerializer + authentication_classes = (JwtAuthentication, authentication.SessionAuthentication) + filter_backends = (filters.NoFilterOnDetailBackend,) + filterset_class = filters.AssignmentConfigurationFilter + pagination_class = PaginationWithPageCount + lookup_field = 'uuid' + + def __init__(self, *args, **kwargs): + self.extra_context = {} + super().__init__(*args, **kwargs) + + def set_assignment_config_created(self, created): + """ + Helper function, used from within a related serializer for creation, to help understand in the context of this + viewset whether an AssignmentConfiguration was created, or if an AssignmentConfiguration with the requested + SubsidyAccessPolicy already existed when creation was attempted. + + This code pattern supports idempotency of the create endpoint. + """ + self.extra_context['created'] = created + + @property + def requested_enterprise_customer_uuid(self): + """ + Look in the query parameters for an enterprise customer UUID. + """ + return utils.get_enterprise_uuid_from_query_params(self.request) + + def get_queryset(self): + """ + A base queryset to list or retrieve ``AssignmentConfiguration`` records. + """ + if self.action == 'list': + return AssignmentConfiguration.objects.filter( + enterprise_customer_uuid=self.requested_enterprise_customer_uuid + ) + + # For all other actions, RBAC controls enterprise-customer-based access, so returning all objects here is safe. + return AssignmentConfiguration.objects.all() + + def get_serializer_class(self): + """ + Overrides the default behavior to return different serializers depending on the request action. + """ + if self.action == 'create': + return serializers.AssignmentConfigurationCreateRequestSerializer + if self.action in ('update', 'partial_update'): + return serializers.AssignmentConfigurationUpdateRequestSerializer + if self.action in ('destroy'): + return serializers.AssignmentConfigurationDeleteRequestSerializer + # list and retrieve use the default serializer. + return self.serializer_class + + @extend_schema( + tags=[CONTENT_ASSIGNMENTS_CONFIGURATION_CRUD_API_TAG], + summary='Retrieve content assignment configuration by UUID.', + responses={ + status.HTTP_200_OK: serializers.AssignmentConfigurationResponseSerializer, + status.HTTP_404_NOT_FOUND: None, # TODO: test that this actually returns 404 instead of 403 on RBAC error. + }, + ) + @permission_required(CONTENT_ASSIGNMENTS_CONFIGURATION_READ_PERMISSION, fn=assignment_config_permission_detail_fn) + def retrieve(self, request, *args, uuid=None, **kwargs): + """ + Retrieves a single ``AssignmentConfiguration`` record by uuid. + """ + return super().retrieve(request, *args, uuid=uuid, **kwargs) + # TODO: implement an ``/assignments`` sub-list endpoint to list all contained assignments. + + @extend_schema( + tags=[CONTENT_ASSIGNMENTS_CONFIGURATION_CRUD_API_TAG], + summary='List content assignment configurations.', + # Inject additional parameters which cannot be inferred form the Serializer. This is easier to do than to + # construct a new Serializer from scratch just to mimic a request schema that supports pagination. + parameters=[ + OpenApiParameter( + name='enterprise_customer_uuid', + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + required=True, + description='List only assignment configurations belonging to the given customer.', + ), + ], + ) + @permission_required( + CONTENT_ASSIGNMENTS_CONFIGURATION_READ_PERMISSION, + fn=lambda request: request.query_params.get('enterprise_customer_uuid') + ) + def list(self, request, *args, **kwargs): + """ + Lists ``AssignmentConfiguration`` records, filtered by the given query parameters. + + TODO: implement a ``subsidy_access_policy`` filter. + """ + return super().list(request, *args, **kwargs) + + @extend_schema( + tags=[CONTENT_ASSIGNMENTS_CONFIGURATION_CRUD_API_TAG], + summary='Get or create a new content assignment configuration for the given subsidy access policy.', + request=serializers.AssignmentConfigurationCreateRequestSerializer, + responses={ + status.HTTP_200_OK: serializers.AssignmentConfigurationResponseSerializer, + status.HTTP_201_CREATED: serializers.AssignmentConfigurationResponseSerializer, + }, + ) + @permission_required( + CONTENT_ASSIGNMENTS_CONFIGURATION_WRITE_PERMISSION, + fn=assignment_config_permission_create_fn, + ) + def create(self, request, *args, **kwargs): + """ + Creates a single ``AssignmentConfiguration`` record, or returns an existing one if one already exists for the + given ``SubsidyAccessPolicy`` uuid. + """ + response = super().create(request, *args, **kwargs) + if not self.extra_context.get('created'): + response.status_code = status.HTTP_200_OK + return response + + @extend_schema( + tags=[CONTENT_ASSIGNMENTS_CONFIGURATION_CRUD_API_TAG], + summary='Partially update (with a PUT) a content assignment configuration by UUID.', + request=serializers.AssignmentConfigurationUpdateRequestSerializer, + responses={ + status.HTTP_200_OK: serializers.AssignmentConfigurationResponseSerializer, + status.HTTP_404_NOT_FOUND: None, + }, + ) + @permission_required(CONTENT_ASSIGNMENTS_CONFIGURATION_WRITE_PERMISSION, fn=assignment_config_permission_detail_fn) + def update(self, request, *args, uuid=None, **kwargs): + """ + Updates a single ``AssignmentConfiguration`` record by uuid. All fields for the update are optional (which is + different from a standard PUT request). + """ + kwargs['partial'] = True + return super().update(request, *args, uuid=uuid, **kwargs) + + @extend_schema( + tags=[CONTENT_ASSIGNMENTS_CONFIGURATION_CRUD_API_TAG], + summary='Partially update (with a PATCH) a content assignment configuration by UUID.', + request=serializers.AssignmentConfigurationUpdateRequestSerializer, + responses={ + status.HTTP_200_OK: serializers.AssignmentConfigurationResponseSerializer, + status.HTTP_404_NOT_FOUND: None, + }, + ) + @permission_required(CONTENT_ASSIGNMENTS_CONFIGURATION_WRITE_PERMISSION, fn=assignment_config_permission_detail_fn) + def partial_update(self, request, *args, uuid=None, **kwargs): + """ + Updates a single ``AssignmentConfiguration`` record by uuid. All fields for the update are optional. + """ + return super().partial_update(request, *args, uuid=uuid, **kwargs) + + @extend_schema( + tags=[CONTENT_ASSIGNMENTS_CONFIGURATION_CRUD_API_TAG], + summary='Soft-delete content assignment configuration by UUID.', + request=serializers.AssignmentConfigurationDeleteRequestSerializer, + responses={ + status.HTTP_200_OK: serializers.AssignmentConfigurationResponseSerializer, + status.HTTP_404_NOT_FOUND: None, + }, + ) + @permission_required(CONTENT_ASSIGNMENTS_CONFIGURATION_WRITE_PERMISSION, fn=assignment_config_permission_detail_fn) + def destroy(self, request, *args, uuid=None, **kwargs): + """ + Soft-delete a single ``AssignmentConfiguration`` record by uuid, and unlink from the associated policy. + + Note: This endpoint supports an optional "reason" request body parameter, representing the description (free + form text) for why the AssignmentConfiguration is being deactivated. + """ + # Note: destroy() must be implemented in the view instead of the serializer because DRF serializers don't + # implement destroy/delete. + + # Collect the "reason" query parameter from request body. + request_serializer = serializers.AssignmentConfigurationDeleteRequestSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + delete_reason = request_serializer.data.get('reason', None) + + try: + assignment_config_to_soft_delete = self.get_queryset().get(uuid=uuid) + except AssignmentConfiguration.DoesNotExist: + return Response(None, status=status.HTTP_404_NOT_FOUND) + + # Custom delete() method should set the active flag to False. + assignment_config_to_soft_delete.delete(reason=delete_reason) + + # Manually unlink this soft-deleted AssignmentConfig from the associated policy. + try: + associated_policy = assignment_config_to_soft_delete.subsidy_access_policy + associated_policy.assignment_configuration = None + associated_policy.save() + except AssignmentConfiguration.subsidy_access_policy.RelatedObjectDoesNotExist: # pylint: disable=no-member + # There just isn't any linked SubsidyAccessPolicy. That is okay. + pass + + response_serializer = serializers.AssignmentConfigurationResponseSerializer(assignment_config_to_soft_delete) + return Response(response_serializer.data, status=status.HTTP_200_OK) diff --git a/enterprise_access/apps/content_assignments/api.py b/enterprise_access/apps/content_assignments/api.py index e830d782..42b5c478 100644 --- a/enterprise_access/apps/content_assignments/api.py +++ b/enterprise_access/apps/content_assignments/api.py @@ -5,7 +5,18 @@ from django.db.models import Sum from .constants import LearnerContentAssignmentStateChoices -from .models import LearnerContentAssignment +from .models import AssignmentConfiguration, LearnerContentAssignment + + +def get_assignment_configuration(uuid): + """ + Returns an `AssignmentConfiguration` record with the given uuid, + or null if no such record exists. + """ + try: + return AssignmentConfiguration.objects.get(uuid=uuid) + except AssignmentConfiguration.DoesNotExist: + return None def get_assignments_for_configuration( diff --git a/enterprise_access/apps/content_assignments/migrations/0005_assignment_configuration_add_fields.py b/enterprise_access/apps/content_assignments/migrations/0005_assignment_configuration_add_fields.py new file mode 100644 index 00000000..e9169894 --- /dev/null +++ b/enterprise_access/apps/content_assignments/migrations/0005_assignment_configuration_add_fields.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.20 on 2023-09-12 18:56 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_assignments', '0004_add_assignment_config_history'), + ] + + operations = [ + migrations.AddField( + model_name='assignmentconfiguration', + name='active', + field=models.BooleanField(db_index=True, default=True, help_text='Whether this assignment configuration is active. Defaults to True.'), + ), + migrations.AddField( + model_name='assignmentconfiguration', + name='enterprise_customer_uuid', + field=models.UUIDField(db_index=True, default=uuid.UUID('00000000-0000-0000-0000-000000000000'), help_text="The owning Enterprise Customer's UUID. Cannot be blank or null."), + ), + migrations.AddField( + model_name='historicalassignmentconfiguration', + name='active', + field=models.BooleanField(db_index=True, default=True, help_text='Whether this assignment configuration is active. Defaults to True.'), + ), + migrations.AddField( + model_name='historicalassignmentconfiguration', + name='enterprise_customer_uuid', + field=models.UUIDField(db_index=True, default=uuid.UUID('00000000-0000-0000-0000-000000000000'), help_text="The owning Enterprise Customer's UUID. Cannot be blank or null."), + ), + ] diff --git a/enterprise_access/apps/content_assignments/models.py b/enterprise_access/apps/content_assignments/models.py index 458ea8b0..93a72d88 100644 --- a/enterprise_access/apps/content_assignments/models.py +++ b/enterprise_access/apps/content_assignments/models.py @@ -1,7 +1,7 @@ """ Models for content_assignments """ -from uuid import uuid4 +from uuid import UUID, uuid4 from django.db import models from django_extensions.db.models import TimeStampedModel @@ -22,8 +22,41 @@ class AssignmentConfiguration(TimeStampedModel): editable=False, unique=True, ) + enterprise_customer_uuid = models.UUIDField( + db_index=True, + null=False, + blank=False, + # This field should, in practice, never be null. + # However, specifying a default quells makemigrations and helps prevent migrate from failing on existing + # populated databases. + default=UUID('0' * 32), + help_text="The owning Enterprise Customer's UUID. Cannot be blank or null.", + ) + active = models.BooleanField( + db_index=True, + default=True, + help_text='Whether this assignment configuration is active. Defaults to True.', + ) + # TODO: Below this line add fields to support rules that control the creation and lifecycle of assignments. + # + # Possibilities include: + # - `max_assignments` to limit the total allowed assignments. + # - `max_age` to control the amount of time before an allocated assignment is auto-expired. + history = HistoricalRecords() + def delete(self, *args, **kwargs): + """ + Perform a soft-delete, overriding the standard delete() method to prevent hard-deletes. + + If this instance was already soft-deleted, invoking delete() is a no-op. + """ + if self.active: + if 'reason' in kwargs and kwargs['reason']: + self._change_reason = kwargs['reason'] # pylint: disable=attribute-defined-outside-init + self.active = False + self.save() + class LearnerContentAssignment(TimeStampedModel): """ diff --git a/enterprise_access/apps/content_assignments/tests/factories.py b/enterprise_access/apps/content_assignments/tests/factories.py index e09d80d3..bc5c318b 100644 --- a/enterprise_access/apps/content_assignments/tests/factories.py +++ b/enterprise_access/apps/content_assignments/tests/factories.py @@ -7,7 +7,7 @@ import factory from faker import Faker -from ..models import LearnerContentAssignment +from ..models import AssignmentConfiguration, LearnerContentAssignment FAKER = Faker() @@ -23,6 +23,18 @@ def random_content_key(): return 'course-v1:{}+{}+{}'.format(*fake_words) +class AssignmentConfigurationFactory(factory.django.DjangoModelFactory): + """ + Base Test factory for the ``AssignmentConfiguration`` model. + """ + class Meta: + model = AssignmentConfiguration + + uuid = factory.LazyFunction(uuid4) + enterprise_customer_uuid = factory.LazyFunction(uuid4) + active = True + + class LearnerContentAssignmentFactory(factory.django.DjangoModelFactory): """ Base Test factory for the ``LearnerContentAssisgnment`` model. diff --git a/enterprise_access/apps/core/constants.py b/enterprise_access/apps/core/constants.py index e649652c..5dc48a8a 100644 --- a/enterprise_access/apps/core/constants.py +++ b/enterprise_access/apps/core/constants.py @@ -17,6 +17,11 @@ SUBSIDY_ACCESS_POLICY_WRITE_PERMISSION = 'subsidy_access_policy.has_write_access' SUBSIDY_ACCESS_POLICY_REDEMPTION_PERMISSION = 'subsidy_access_policy.has_redemption_access' +CONTENT_ASSIGNMENTS_OPERATOR_ROLE = 'enterprise_access_content_assignment_operator' +CONTENT_ASSIGNMENTS_ADMIN_ROLE = 'enterprise_access_content_assignment_admin' +CONTENT_ASSIGNMENTS_CONFIGURATION_READ_PERMISSION = 'content_assignment_configuration.has_read_access' +CONTENT_ASSIGNMENTS_CONFIGURATION_WRITE_PERMISSION = 'content_assignment_configuration.has_write_access' + ALL_ACCESS_CONTEXT = '*' diff --git a/enterprise_access/apps/core/rules.py b/enterprise_access/apps/core/rules.py index 7365a659..78e18569 100644 --- a/enterprise_access/apps/core/rules.py +++ b/enterprise_access/apps/core/rules.py @@ -139,6 +139,53 @@ def has_explicit_access_to_policy_learner(user, enterprise_customer_uuid): return _has_explicit_access_to_role(user, enterprise_customer_uuid, constants.SUBSIDY_ACCESS_POLICY_LEARNER_ROLE) +# Content Assignment rule predicates: +@rules.predicate +def has_implicit_access_to_content_assignment_operator(_, enterprise_customer_uuid): + """ + Check that if request user has implicit access to the given enterprise UUID for the + `CONTENT_ASSIGNMENTS_OPERATOR_ROLE` feature role. + + Returns: + boolean: whether the request user has access. + """ + return _has_implicit_access_to_role(_, enterprise_customer_uuid, constants.CONTENT_ASSIGNMENTS_OPERATOR_ROLE) + + +@rules.predicate +def has_explicit_access_to_content_assignment_operator(user, enterprise_customer_uuid): + """ + Check that if request user has explicit access to `CONTENT_ASSIGNMENTS_OPERATOR_ROLE` feature role. + + Returns: + boolean: whether the request user has access. + """ + return _has_explicit_access_to_role(user, enterprise_customer_uuid, constants.CONTENT_ASSIGNMENTS_OPERATOR_ROLE) + + +@rules.predicate +def has_implicit_access_to_content_assignment_admin(_, enterprise_customer_uuid): + """ + Check that if request user has implicit access to the given enterprise UUID for the + `CONTENT_ASSIGNMENTS_ADMIN_ROLE` feature role. + + Returns: + boolean: whether the request user has access. + """ + return _has_implicit_access_to_role(_, enterprise_customer_uuid, constants.CONTENT_ASSIGNMENTS_ADMIN_ROLE) + + +@rules.predicate +def has_explicit_access_to_content_assignment_admin(user, enterprise_customer_uuid): + """ + Check that if request user has explicit access to `CONTENT_ASSIGNMENTS_ADMIN_ROLE` feature role. + + Returns: + boolean: whether the request user has access. + """ + return _has_explicit_access_to_role(user, enterprise_customer_uuid, constants.CONTENT_ASSIGNMENTS_ADMIN_ROLE) + + ###################################################### # Consolidate implicit and explicit rule predicates. # ###################################################### @@ -167,6 +214,18 @@ def has_explicit_access_to_policy_learner(user, enterprise_customer_uuid): ) +# pylint: disable=unsupported-binary-operation +has_content_assignment_operator_access = ( + has_implicit_access_to_content_assignment_operator | has_explicit_access_to_content_assignment_operator +) + + +# pylint: disable=unsupported-binary-operation +has_content_assignment_admin_access = ( + has_implicit_access_to_content_assignment_admin | has_explicit_access_to_content_assignment_admin +) + + rules.add_perm( constants.REQUESTS_ADMIN_ACCESS_PERMISSION, has_subsidy_request_admin_access, @@ -202,3 +261,17 @@ def has_explicit_access_to_policy_learner(user, enterprise_customer_uuid): constants.SUBSIDY_ACCESS_POLICY_REDEMPTION_PERMISSION, has_subsidy_access_policy_operator_access | has_subsidy_access_policy_learner_access ) + + +# Grants content assignment configuration read permission if the user is a content assignment configuration admin. +rules.add_perm( + constants.CONTENT_ASSIGNMENTS_CONFIGURATION_READ_PERMISSION, + has_content_assignment_operator_access | has_content_assignment_admin_access, +) + + +# Grants content assignment configuration write permission if the user is a content assignment configuration operator. +rules.add_perm( + constants.CONTENT_ASSIGNMENTS_CONFIGURATION_WRITE_PERMISSION, + has_content_assignment_operator_access, +) diff --git a/enterprise_access/apps/subsidy_access_policy/models.py b/enterprise_access/apps/subsidy_access_policy/models.py index 2f6a5f74..e8e064c0 100644 --- a/enterprise_access/apps/subsidy_access_policy/models.py +++ b/enterprise_access/apps/subsidy_access_policy/models.py @@ -128,8 +128,21 @@ class Meta: 'Defaults to null, which means that no such maximum exists.' ), ) + assignment_configuration = models.OneToOneField( + AssignmentConfiguration, + related_name='subsidy_access_policy', + on_delete=models.SET_NULL, + db_index=True, + null=True, + ) + + ################# + # CUSTOM FIELDS # + ################# + # Fields and properties below pertain to custom features for different policy types defined by sub-classes of + # SubsidyAccessPolicy. - # Update this list to match the following "custom" fields, which are ones only used by sub-classes. + # Update this list to match all the "custom" fields below: ALL_CUSTOM_FIELDS = [ 'per_learner_enrollment_limit', 'per_learner_spend_limit', @@ -160,13 +173,6 @@ class Meta: 'Required if policy_type = "PerLearnerSpendCreditAccessPolicy".' ), ) - assignment_configuration = models.OneToOneField( - AssignmentConfiguration, - related_name='subsidy_access_policy', - on_delete=models.SET_NULL, - db_index=True, - null=True, - ) # Customized version of HistoricalRecords to enable history tracking on child proxy models. See # ProxyAwareHistoricalRecords docstring for more info. diff --git a/enterprise_access/settings/base.py b/enterprise_access/settings/base.py index 54b210c6..11039523 100644 --- a/enterprise_access/settings/base.py +++ b/enterprise_access/settings/base.py @@ -4,6 +4,8 @@ from corsheaders.defaults import default_headers as corsheaders_default_headers from enterprise_access.apps.core.constants import ( + CONTENT_ASSIGNMENTS_ADMIN_ROLE, + CONTENT_ASSIGNMENTS_OPERATOR_ROLE, REQUESTS_ADMIN_ROLE, REQUESTS_LEARNER_ROLE, SUBSIDY_ACCESS_POLICY_LEARNER_ROLE, @@ -310,10 +312,12 @@ def root(*path_fragments): SYSTEM_TO_FEATURE_ROLE_MAPPING = { SYSTEM_ENTERPRISE_OPERATOR_ROLE: [ SUBSIDY_ACCESS_POLICY_OPERATOR_ROLE, + CONTENT_ASSIGNMENTS_OPERATOR_ROLE, REQUESTS_ADMIN_ROLE, ], SYSTEM_ENTERPRISE_ADMIN_ROLE: [ SUBSIDY_ACCESS_POLICY_LEARNER_ROLE, + CONTENT_ASSIGNMENTS_ADMIN_ROLE, REQUESTS_ADMIN_ROLE, ], SYSTEM_ENTERPRISE_LEARNER_ROLE: [