From 6552a017a74007b1d620cd337d678c5224e36cd9 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Thu, 30 Nov 2023 13:02:41 -0500 Subject: [PATCH] feat: add cancel_all and remind_all for assignments --- .../api/v1/tests/test_assignment_views.py | 262 +++++++++++++++++- .../content_assignments/assignments_admin.py | 108 +++++++- 2 files changed, 365 insertions(+), 5 deletions(-) diff --git a/enterprise_access/apps/api/v1/tests/test_assignment_views.py b/enterprise_access/apps/api/v1/tests/test_assignment_views.py index e4805a8d..ec9d9663 100644 --- a/enterprise_access/apps/api/v1/tests/test_assignment_views.py +++ b/enterprise_access/apps/api/v1/tests/test_assignment_views.py @@ -560,7 +560,11 @@ def test_remind(self): 'assignment_uuids': [str(self.assignment_allocated_post_link.uuid)], } - response = self.client.post(remind_url, query_params) + with mock.patch( + 'enterprise_access.apps.content_assignments.api.send_reminder_email_for_pending_assignment' + ) as mock_remind_task: + response = self.client.post(remind_url, query_params) + mock_remind_task.delay.assert_called_once_with(self.assignment_allocated_post_link.uuid) # Verify the API response. assert response.status_code == status.HTTP_200_OK @@ -840,3 +844,259 @@ def test_list(self, role_context_dict): expected_assignment_uuids = {assignment.uuid for assignment in expected_assignments_for_requester} actual_assignment_uuids = {UUID(assignment['uuid']) for assignment in response.json()['results']} assert actual_assignment_uuids == expected_assignment_uuids + + +@ddt.ddt +class TestRemindAllCancelAll(CRUDViewTestMixin, APITest): + """ + Tests for the remind-all and cancel-all actions. + """ + def setUp(self): # pylint: disable=super-method-not-called + """ + We don't need all the extra records created in super().setUp() + """ + self.client.logout() + + @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_remind_all(self, role_context_dict): + """ + Tests the remind-all view. + """ + self.set_jwt_cookie([role_context_dict]) + assignment_1 = LearnerContentAssignmentFactory( + state=LearnerContentAssignmentStateChoices.ALLOCATED, + lms_user_id=TEST_OTHER_LMS_USER_ID, + transaction_uuid=None, + assignment_configuration=self.assignment_configuration, + ) + assignment_2 = LearnerContentAssignmentFactory( + state=LearnerContentAssignmentStateChoices.ALLOCATED, + learner_email=TEST_EMAIL, + lms_user_id=TEST_USER_ID, + transaction_uuid=None, + assignment_configuration=self.assignment_configuration, + ) + + remind_kwargs = { + 'assignment_configuration_uuid': str(self.assignment_configuration.uuid), + } + remind_url = reverse('api:v1:admin-assignments-remind-all', kwargs=remind_kwargs) + + with mock.patch( + 'enterprise_access.apps.content_assignments.api.send_reminder_email_for_pending_assignment' + ) as mock_remind_task: + response = self.client.post(remind_url) + mock_remind_task.delay.assert_has_calls( + [mock.call(assignment_1.uuid), mock.call(assignment_2.uuid)], + any_order=True, + ) + + # Verify the API response. + assert response.status_code == status.HTTP_202_ACCEPTED + + for assignment in (assignment_1, assignment_2): + assignment.refresh_from_db() + self.assertEqual(assignment.state, LearnerContentAssignmentStateChoices.ALLOCATED) + + def test_learner_remind_all_403(self): + """ + Learners can't perform the remind-all action. + """ + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + ]) + + remind_kwargs = { + 'assignment_configuration_uuid': str(self.assignment_configuration.uuid), + } + remind_url = reverse('api:v1:admin-assignments-remind-all', kwargs=remind_kwargs) + + response = self.client.post(remind_url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_remind_all_no_remindable_assignments(self): + """ + Tests the scenario where there are no assignments in a remindable state. + """ + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + ]) + + LearnerContentAssignmentFactory( + state=LearnerContentAssignmentStateChoices.ACCEPTED, + lms_user_id=TEST_OTHER_LMS_USER_ID, + transaction_uuid=None, + assignment_configuration=self.assignment_configuration, + ) + + remind_kwargs = { + 'assignment_configuration_uuid': str(self.assignment_configuration.uuid), + } + remind_url = reverse('api:v1:admin-assignments-remind-all', kwargs=remind_kwargs) + + with mock.patch( + 'enterprise_access.apps.content_assignments.api.remind_assignments' + ) as mock_remind_function: + response = self.client.post(remind_url) + self.assertFalse(mock_remind_function.called) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_remind_all_unlikely_thing_to_happen(self): + """ + Tests the unlikely scenario where we attempt to remind assignments + that are not in a remindable state, even after filtering at the view layer + """ + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + ]) + + LearnerContentAssignmentFactory( + state=LearnerContentAssignmentStateChoices.ALLOCATED, + lms_user_id=TEST_OTHER_LMS_USER_ID, + transaction_uuid=None, + assignment_configuration=self.assignment_configuration, + ) + + remind_kwargs = { + 'assignment_configuration_uuid': str(self.assignment_configuration.uuid), + } + remind_url = reverse('api:v1:admin-assignments-remind-all', kwargs=remind_kwargs) + + with mock.patch( + 'enterprise_access.apps.content_assignments.api.remind_assignments' + ) as mock_remind_function: + mock_remind_function.return_value = { + 'non_remindable_assignments': mock.ANY, + } + response = self.client.post(remind_url) + + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + + @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_cancel_all(self, role_context_dict): + """ + Tests the cancel-all view. + """ + self.set_jwt_cookie([role_context_dict]) + assignment_1 = LearnerContentAssignmentFactory( + state=LearnerContentAssignmentStateChoices.ALLOCATED, + lms_user_id=TEST_OTHER_LMS_USER_ID, + transaction_uuid=None, + assignment_configuration=self.assignment_configuration, + ) + assignment_2 = LearnerContentAssignmentFactory( + state=LearnerContentAssignmentStateChoices.ALLOCATED, + learner_email=TEST_EMAIL, + lms_user_id=TEST_USER_ID, + transaction_uuid=None, + assignment_configuration=self.assignment_configuration, + ) + + cancel_kwargs = { + 'assignment_configuration_uuid': str(self.assignment_configuration.uuid), + } + cancel_url = reverse('api:v1:admin-assignments-cancel-all', kwargs=cancel_kwargs) + + with mock.patch( + 'enterprise_access.apps.content_assignments.tasks.send_cancel_email_for_pending_assignment' + ) as mock_cancel_task: + response = self.client.post(cancel_url) + mock_cancel_task.delay.assert_has_calls( + [mock.call(assignment_1.uuid), mock.call(assignment_2.uuid)], + any_order=True, + ) + + # Verify the API response. + assert response.status_code == status.HTTP_202_ACCEPTED + + for assignment in (assignment_1, assignment_2): + assignment.refresh_from_db() + self.assertEqual(assignment.state, LearnerContentAssignmentStateChoices.CANCELLED) + + def test_learner_cancel_all_403(self): + """ + Learners can't perform the cancel-all action. + """ + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + ]) + + cancel_kwargs = { + 'assignment_configuration_uuid': str(self.assignment_configuration.uuid), + } + cancel_url = reverse('api:v1:admin-assignments-cancel-all', kwargs=cancel_kwargs) + + response = self.client.post(cancel_url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cancel_all_no_cancelable_assignments(self): + """ + Tests the scenario where there are no assignments in a cancelable state. + """ + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + ]) + + LearnerContentAssignmentFactory( + state=LearnerContentAssignmentStateChoices.ACCEPTED, + lms_user_id=TEST_OTHER_LMS_USER_ID, + transaction_uuid=None, + assignment_configuration=self.assignment_configuration, + ) + + cancel_kwargs = { + 'assignment_configuration_uuid': str(self.assignment_configuration.uuid), + } + cancel_url = reverse('api:v1:admin-assignments-cancel-all', kwargs=cancel_kwargs) + + with mock.patch( + 'enterprise_access.apps.content_assignments.api.cancel_assignments' + ) as mock_cancel_function: + response = self.client.post(cancel_url) + self.assertFalse(mock_cancel_function.called) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_cancel_all_unlikely_thing_to_happen(self): + """ + Tests the unlikely scenario where we attempt to cancel assignments + that are not in a cancelable state, even after filtering at the view layer + """ + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, 'context': str(TEST_ENTERPRISE_UUID)}, + ]) + + LearnerContentAssignmentFactory( + state=LearnerContentAssignmentStateChoices.ALLOCATED, + lms_user_id=TEST_OTHER_LMS_USER_ID, + transaction_uuid=None, + assignment_configuration=self.assignment_configuration, + ) + + cancel_kwargs = { + 'assignment_configuration_uuid': str(self.assignment_configuration.uuid), + } + cancel_url = reverse('api:v1:admin-assignments-cancel-all', kwargs=cancel_kwargs) + + with mock.patch( + 'enterprise_access.apps.content_assignments.api.cancel_assignments' + ) as mock_cancel_function: + mock_cancel_function.return_value = { + 'non_cancelable': mock.ANY, + } + response = self.client.post(cancel_url) + + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) diff --git a/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py b/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py index 2f11082d..b8181716 100644 --- a/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py +++ b/enterprise_access/apps/api/v1/views/content_assignments/assignments_admin.py @@ -18,7 +18,10 @@ ) from enterprise_access.apps.api.v1.views.utils import PaginationWithPageCount from enterprise_access.apps.content_assignments import api as assignments_api -from enterprise_access.apps.content_assignments.constants import AssignmentLearnerStates +from enterprise_access.apps.content_assignments.constants import ( + AssignmentLearnerStates, + LearnerContentAssignmentStateChoices +) from enterprise_access.apps.content_assignments.models import LearnerContentAssignment from enterprise_access.apps.core.constants import ( CONTENT_ASSIGNMENT_ADMIN_READ_PERMISSION, @@ -120,12 +123,18 @@ def get_queryset(self): # safe (and more performant). pass - # Annotate extra dynamic fields used by this viewset for DRF-supported ordering and filtering: + # Annotate extra dynamic fields used by this viewset for DRF-supported ordering and filtering, + # but only for the list and retrieve actions: # * learner_state # * learner_state_sort_order # * recent_action # * recent_action_time - queryset = LearnerContentAssignment.annotate_dynamic_fields_onto_queryset(queryset).prefetch_related('actions') + if self.action in ('list', 'retrieve'): + queryset = LearnerContentAssignment.annotate_dynamic_fields_onto_queryset( + queryset, + ).prefetch_related( + 'actions', + ) return queryset @@ -147,6 +156,10 @@ def retrieve(self, request, *args, uuid=None, **kwargs): @extend_schema( tags=[CONTENT_ASSIGNMENT_ADMIN_CRUD_API_TAG], summary='List content assignments.', + responses={ + status.HTTP_200_OK: serializers.LearnerContentAssignmentAdminResponseSerializer, + status.HTTP_404_NOT_FOUND: None, + }, ) @permission_required(CONTENT_ASSIGNMENT_ADMIN_READ_PERMISSION, fn=assignment_admin_permission_fn) def list(self, request, *args, **kwargs): @@ -202,6 +215,49 @@ def cancel(self, request, *args, **kwargs): except Exception: # pylint: disable=broad-except return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + @extend_schema( + tags=[CONTENT_ASSIGNMENT_ADMIN_CRUD_API_TAG], + summary='Cancel all assignments for the requested assignment configuration.', + request=None, + responses={ + status.HTTP_202_ACCEPTED: None, + status.HTTP_404_NOT_FOUND: None, + status.HTTP_422_UNPROCESSABLE_ENTITY: None, + }, + ) + @permission_required(CONTENT_ASSIGNMENT_ADMIN_WRITE_PERMISSION, fn=assignment_admin_permission_fn) + @action(detail=False, methods=['post'], url_path='cancel-all') + def cancel_all(self, request, *args, **kwargs): + """ + Cancel all ``LearnerContentAssignment`` associated with the given assignment configuration. + + Raises: + 404 if any of the assignments were not found + 422 if any of the assignments threw an error (not found or not cancelable) + """ + assignments = self.get_queryset().filter( + assignment_configuration__uuid=self.requested_assignment_configuration_uuid, + state__in=LearnerContentAssignmentStateChoices.CANCELABLE_STATES, + ) + if not assignments: + return Response(status=status.HTTP_404_NOT_FOUND) + + try: + response = assignments_api.cancel_assignments(assignments) + if non_cancelable_assignments := response.get('non_cancelable'): + # This is very unlikely to occur, because we filter down to only the cancelable + # assignments before calling `cancel_assignments()`, and that function + # only declares assignments to be non-cancelable if they are not + # in the set of cancelable states. + logger.error( + 'There were non-cancelable assignments in cancel-all: %s', + non_cancelable_assignments, + ) + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + return Response(status=status.HTTP_202_ACCEPTED) + except Exception: # pylint: disable=broad-except + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + @extend_schema( tags=[CONTENT_ASSIGNMENT_ADMIN_CRUD_API_TAG], summary='Remind assignments by UUID.', @@ -227,7 +283,8 @@ def remind(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) assignments = self.get_queryset().filter( assignment_configuration__uuid=self.requested_assignment_configuration_uuid, - uuid__in=serializer.data['assignment_uuids']) + uuid__in=serializer.data['assignment_uuids'], + ) try: response = assignments_api.remind_assignments(assignments) if response.get('non_remindable_assignments') or len(assignments) < len(request.data['assignment_uuids']): @@ -235,3 +292,46 @@ def remind(self, request, *args, **kwargs): return Response(status=status.HTTP_200_OK) except Exception: # pylint: disable=broad-except return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + + @extend_schema( + tags=[CONTENT_ASSIGNMENT_ADMIN_CRUD_API_TAG], + summary='Remind all assignments for the given assignment configuration.', + request=None, + responses={ + status.HTTP_202_ACCEPTED: None, + status.HTTP_404_NOT_FOUND: None, + status.HTTP_422_UNPROCESSABLE_ENTITY: None, + }, + ) + @permission_required(CONTENT_ASSIGNMENT_ADMIN_WRITE_PERMISSION, fn=assignment_admin_permission_fn) + @action(detail=False, methods=['post'], url_path='remind-all') + def remind_all(self, request, *args, **kwargs): + """ + Send reminders for all assignments related to the given assignment configuration. + + Raises: + 404 if any of the assignments were not found + 422 if any of the assignments threw an error (not found or not remindable) + """ + assignments = self.get_queryset().filter( + assignment_configuration__uuid=self.requested_assignment_configuration_uuid, + state__in=LearnerContentAssignmentStateChoices.REMINDABLE_STATES, + ) + if not assignments: + return Response(status=status.HTTP_404_NOT_FOUND) + + try: + response = assignments_api.remind_assignments(assignments) + if non_remindable_assignments := response.get('non_remindable_assignments'): + # This is very unlikely to occur, because we filter down to only the remindable + # assignments before calling `remind_assignments()`, and that function + # only declares assignments to be non-remindable if they are not + # in the set of remindable states. + logger.error( + 'There were non-remindable assignments in remind-all: %s', + non_remindable_assignments, + ) + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + return Response(status=status.HTTP_202_ACCEPTED) + except Exception: # pylint: disable=broad-except + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY)