diff --git a/enterprise_access/apps/api/serializers/__init__.py b/enterprise_access/apps/api/serializers/__init__.py index 50fae484..b8a6e9ea 100644 --- a/enterprise_access/apps/api/serializers/__init__.py +++ b/enterprise_access/apps/api/serializers/__init__.py @@ -17,6 +17,8 @@ AssignmentConfigurationUpdateRequestSerializer ) from .subsidy_access_policy import ( + GroupMemberWithAggregatesRequestSerializer, + GroupMemberWithAggregatesResponseSerializer, SubsidyAccessPolicyAllocateRequestSerializer, SubsidyAccessPolicyAllocationResponseSerializer, SubsidyAccessPolicyCanRedeemElementResponseSerializer, diff --git a/enterprise_access/apps/api/serializers/subsidy_access_policy.py b/enterprise_access/apps/api/serializers/subsidy_access_policy.py index 39463560..f131c2c0 100644 --- a/enterprise_access/apps/api/serializers/subsidy_access_policy.py +++ b/enterprise_access/apps/api/serializers/subsidy_access_policy.py @@ -712,3 +712,114 @@ class SubsidyAccessPolicyAllocationResponseSerializer(serializers.Serializer): 'learner email(s), and content for this action.' ), ) + + +class GroupMembersDetailsSerializer(serializers.Serializer): + """ + Sub-serializer for the response objects associated with the ``get_group_member_data_with_aggregates`` + endpoint. Contains data captured by the ``member_details`` field. + """ + user_email = serializers.CharField( + help_text='The email associated with the group member user record.', + ) + + user_name = serializers.CharField( + required=False, + help_text='The full (first and last) name of the group member user records. Is blank if the member is pending', + ) + + +class GroupMemberWithAggregatesResponseSerializer(serializers.Serializer): + """ + A read-only serializer for responding to requests to the ``get_group_member_data_with_aggregates`` endpoint. + + For view: SubsidyAccessPolicyGroupViewset.get_group_member_data_with_aggregates + """ + enterprise_customer_user_id = serializers.IntegerField( + help_text=('LMS enterprise user ID associated with the membership.'), + ) + lms_user_id = serializers.IntegerField( + help_text=('LMS auth user ID associated with the membership.'), + ) + pending_enterprise_customer_user_id = serializers.IntegerField( + help_text=('LMS pending enterprise user ID associated with the membership.'), + ) + enterprise_group_membership_uuid = serializers.UUIDField( + help_text=('The UUID associated with the group membership record.') + ) + member_details = GroupMembersDetailsSerializer( + help_text='User record associated with the membership details, including name and email.' + ) + recent_action = serializers.CharField( + help_text='String representation of the most recent action associated with the creation of the membership.', + ) + status = serializers.CharField( + help_text='String representation of the current membership status.', + ) + activated_at = serializers.DateTimeField( + help_text='Datetime at which the membership and user record when from pending to realized.', + ) + enrollment_count = serializers.IntegerField( + help_text=( + 'Number of enrollments, made by a group member, that are associated with the subsidy access policy' + 'connected to the group.' + ), + min_value=0, + ) + + +class GroupMemberWithAggregatesRequestSerializer(serializers.Serializer): + """ + Request Serializer to validate policy group ``get_group_member_data_with_aggregates`` endpoint GET data. + + For view: SubsidyAccessPolicyGroupViewset.get_group_member_data_with_aggregates + """ + group_uuid = serializers.UUIDField( + required=False, + help_text=( + 'The group from which to select members.' + 'Leave blank to fetch group members for every group associated with the policy', + ) + ) + user_query = serializers.CharField(required=False, max_length=320) + sort_by = serializers.ChoiceField( + choices=[ + ('member_details', 'member_details'), + ('status', 'status'), + ('recent_action', 'recent_action') + ], + required=False, + ) + fetch_removed = serializers.BooleanField( + required=False, + help_text="Set to True to fetch and include removed membership records." + ) + is_reversed = serializers.BooleanField( + required=False, + help_text="Set to True to reverse the order in which records are returned." + ) + format_csv = serializers.BooleanField( + required=False, + help_text="Set to True to return data in a csv format." + ) + page = serializers.IntegerField( + required=False, + help_text=('Determines which page of enterprise group members to fetch. Leave blank for all data'), + min_value=1, + ) + traverse_pagination = serializers.BooleanField( + required=False, + default=False, + help_text=('Set to True to traverse over and return all group members records across all pages of data.') + ) + + def validate(self, attrs): + """ + Raises a ValidationError both `traverse_pagination` and `page` are both supplied as they enable conflicting + behaviors. + """ + if bool(attrs.get('page')) == bool(attrs.get('traverse_pagination')): + raise serializers.ValidationError( + "Can only support one param of the following at a time: `page` or `traverse_pagination`" + ) + return attrs diff --git a/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py b/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py index 066d511e..b4568546 100644 --- a/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py +++ b/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py @@ -1,6 +1,7 @@ """ Tests for Enterprise Access Subsidy Access Policy app API v1 views. """ +import copy from datetime import datetime, timedelta from operator import itemgetter from unittest import mock @@ -2102,6 +2103,119 @@ def test_can_redeem_subsidy_client_http_error(self, mock_get_client): } +@ddt.ddt +class TestSubsidyAccessPolicyGroupViewset(CRUDViewTestMixin, APITestWithMocks): + """ + Tests for the subsidy access policy group association viewset + """ + + def setUp(self): + super().setUp() + self.assignment_configuration = AssignmentConfigurationFactory( + enterprise_customer_uuid=self.enterprise_uuid, + ) + self.assigned_learner_credit_policy = AssignedLearnerCreditAccessPolicyFactory( + display_name='An assigned learner credit policy, for the test customer.', + enterprise_customer_uuid=self.enterprise_uuid, + active=True, + assignment_configuration=self.assignment_configuration, + spend_limit=1000000, + ) + self.subsidy_access_policy_can_redeem_endpoint = reverse( + "api:v1:aggregated-subsidy-enrollments", + kwargs={"uuid": self.assigned_learner_credit_policy.uuid}, + ) + self.set_jwt_cookie([{ + 'system_wide_role': SYSTEM_ENTERPRISE_ADMIN_ROLE, + 'context': self.enterprise_uuid, + }]) + self.mock_fetch_group_members = { + "next": None, + "previous": None, + "count": 1, + "num_pages": 1, + "current_page": 1, + "start": 1, + "results": [ + { + "lms_user_id": 1, + "enterprise_customer_user_id": 2, + "pending_enterprise_customer_user_id": None, + "enterprise_group_membership_uuid": uuid4(), + "member_details": { + "user_email": "foobar@example.com", + "user_name": "foobar" + }, + "recent_action": "Accepted: April 24, 2024", + "status": "accepted", + }, + ] + } + + @staticmethod + def _get_csv_data_rows(response): + """ + Helper method to create list of str for each row in the CSV data + returned from the licenses CSV endpoint. As is expected, each + column in a given row is comma separated. + """ + return str(response.content)[2:].split('\\r\\n')[:-1] + + def test_get_group_member_data_with_aggregates_serializer_validation(self): + """ + Test that the `get_group_member_data_with_aggregates` endpoint will validate request params + """ + response = self.client.get( + self.subsidy_access_policy_can_redeem_endpoint, + {'traverse_pagination': True, 'group_uuid': uuid4(), 'page': 1}, + ) + assert 'Can only support one param of the following at a time: `page` or `traverse_pagination`' in \ + response.data.get('non_field_errors', [])[0] + + @mock.patch('enterprise_access.apps.api.v1.views.subsidy_access_policy.LmsApiClient') + @mock.patch( + 'enterprise_access.apps.api.v1.views.subsidy_access_policy.get_and_cache_subsidy_learners_aggregate_data' + ) + def test_get_group_member_data_with_aggregates_success( + self, + mock_subsidy_learners_aggregate_data_cache, + mock_lms_api_client, + ): + """ + Test that the `get_group_member_data_with_aggregates` endpoint can zip and forward the platform enterprise + group members list response + """ + mock_subsidy_learners_aggregate_data_cache.return_value = {1: 99} + mock_lms_api_client.return_value.fetch_group_members.return_value = self.mock_fetch_group_members + response = self.client.get(self.subsidy_access_policy_can_redeem_endpoint, {'group_uuid': uuid4(), 'page': 1}) + expected_response = copy.deepcopy(self.mock_fetch_group_members) + expected_response['results'][0]['enrollment_count'] = 99 + assert response.headers.get('Content-Type') == 'application/json' + assert response.data == expected_response + + @mock.patch('enterprise_access.apps.api.v1.views.subsidy_access_policy.LmsApiClient') + @mock.patch( + 'enterprise_access.apps.api.v1.views.subsidy_access_policy.get_and_cache_subsidy_learners_aggregate_data' + ) + def test_get_group_member_data_with_aggregates_csv_format( + self, + mock_subsidy_learners_aggregate_data_cache, + mock_lms_api_client, + ): + """ + Test that the `get_group_member_data_with_aggregates` endpoint can properly format a csv formatted response. + """ + mock_subsidy_learners_aggregate_data_cache.return_value = {1: 99} + mock_lms_api_client.return_value.fetch_group_members.return_value = self.mock_fetch_group_members + query_params = {'group_uuid': uuid4(), "format_csv": True, 'traverse_pagination': True} + response = self.client.get(self.subsidy_access_policy_can_redeem_endpoint, query_params) + rows = self._get_csv_data_rows(response) + assert response.content_type == 'text/csv' + assert rows[0] == 'email,name,Recent Action,Enrollment Number,Activation Date,status' + # Make sure the `subsidy_learners_aggregate_data` has been zipped with group membership data + assert rows[1] == 'foobar@example.com,foobar,"Accepted: April 24, 2024",99,,accepted' + + @ddt.ddt class TestAssignedSubsidyAccessPolicyCanRedeemView(BaseCanRedeemTestMixin, APITestWithMocks): """ diff --git a/enterprise_access/apps/api/v1/urls.py b/enterprise_access/apps/api/v1/urls.py index 68954b62..1357ecf9 100644 --- a/enterprise_access/apps/api/v1/urls.py +++ b/enterprise_access/apps/api/v1/urls.py @@ -1,11 +1,11 @@ """ API v1 URLs. """ +from django.urls import path from rest_framework.routers import DefaultRouter from enterprise_access.apps.api.v1 import views app_name = 'v1' -urlpatterns = [] router = DefaultRouter() @@ -27,4 +27,12 @@ 'assignments', ) +urlpatterns = [ + path( + 'subsidy-access-policies//group-members', + views.SubsidyAccessPolicyGroupViewset.as_view({'get': 'get_group_member_data_with_aggregates'}), + name='aggregated-subsidy-enrollments' + ), +] + urlpatterns += router.urls diff --git a/enterprise_access/apps/api/v1/views/__init__.py b/enterprise_access/apps/api/v1/views/__init__.py index e929e4e0..7961a1b1 100644 --- a/enterprise_access/apps/api/v1/views/__init__.py +++ b/enterprise_access/apps/api/v1/views/__init__.py @@ -13,6 +13,7 @@ from .content_assignments.assignments_admin import LearnerContentAssignmentAdminViewSet from .subsidy_access_policy import ( SubsidyAccessPolicyAllocateViewset, + SubsidyAccessPolicyGroupViewset, SubsidyAccessPolicyRedeemViewset, SubsidyAccessPolicyViewSet ) diff --git a/enterprise_access/apps/api/v1/views/subsidy_access_policy.py b/enterprise_access/apps/api/v1/views/subsidy_access_policy.py index ae002461..dc12873f 100644 --- a/enterprise_access/apps/api/v1/views/subsidy_access_policy.py +++ b/enterprise_access/apps/api/v1/views/subsidy_access_policy.py @@ -19,6 +19,7 @@ from rest_framework.exceptions import APIException, NotFound from rest_framework.generics import get_object_or_404 from rest_framework.response import Response +from rest_framework_csv.renderers import CSVRenderer from enterprise_access.apps.api import filters, serializers, utils from enterprise_access.apps.api.mixins import UserDetailsFromJwtMixin @@ -53,10 +54,14 @@ SubsidyAPIHTTPError ) from enterprise_access.apps.subsidy_access_policy.models import ( + PolicyGroupAssociation, SubsidyAccessPolicy, SubsidyAccessPolicyLockAttemptFailed ) -from enterprise_access.apps.subsidy_access_policy.subsidy_api import get_redemptions_by_content_and_policy_for_learner +from enterprise_access.apps.subsidy_access_policy.subsidy_api import ( + get_and_cache_subsidy_learners_aggregate_data, + get_redemptions_by_content_and_policy_for_learner +) from .utils import PaginationWithPageCount @@ -65,6 +70,7 @@ SUBSIDY_ACCESS_POLICY_CRUD_API_TAG = 'Subsidy Access Policies CRUD' SUBSIDY_ACCESS_POLICY_REDEMPTION_API_TAG = 'Subsidy Access Policy Redemption' SUBSIDY_ACCESS_POLICY_ALLOCATION_API_TAG = 'Subsidy Access Policy Allocation' +GROUP_MEMBER_DATA_WITH_AGGREGATES_API_TAG = 'Group Member Data With Aggregates' def policy_permission_detail_fn(request, *args, uuid=None, **kwargs): @@ -851,3 +857,152 @@ def allocate(self, request, *args, **kwargs): } ] raise AllocationRequestException(detail=error_detail) from exc + + +class GroupMembersWithAggregatesCsvRenderer(CSVRenderer): + """ + Custom Renderer class to ensure csv column ordering and labelling. + """ + header = [ + 'member_details.user_email', + 'member_details.user_name', + 'recent_action', + 'enrollment_count', + 'activated_at', + 'status' + ] + labels = { + 'member_details.user_email': 'email', + 'member_details.user_name': 'name', + 'recent_action': 'Recent Action', + 'enrollment_count': 'Enrollment Number', + 'activated_at': 'Activation Date', + } + + +class SubsidyAccessPolicyGroupViewset(UserDetailsFromJwtMixin, PermissionRequiredMixin, viewsets.GenericViewSet): + """ + Viewset for Subsidy Access Policy Group Associations. + """ + permission_classes = (permissions.IsAuthenticated,) + permission_required = SUBSIDY_ACCESS_POLICY_READ_PERMISSION + authentication_classes = (JwtAuthentication, authentication.SessionAuthentication) + filter_backends = (filters.NoFilterOnDetailBackend,) + lookup_field = 'uuid' + + @cached_property + def enterprise_customer_uuid(self): + """ + Returns the enterprise customer uuid from request data based. + """ + enterprise_uuid = '' + policy_uuid = self.kwargs.get('uuid') + with suppress(ValidationError): # Ignore if `policy_uuid` is not a valid uuid + policy = SubsidyAccessPolicy.objects.filter(uuid=policy_uuid).first() + if policy: + enterprise_uuid = policy.enterprise_customer_uuid + return enterprise_uuid + + def get_permission_object(self): + """ + Returns the enterprise uuid to verify that requesting user possess the enterprise learner or admin role. + """ + return str(self.enterprise_customer_uuid) + + def get_queryset(self): + """ + Required by Django Generic Viewsets, since this data is fetched remotely and constructed there is no interal + notion of a queryset + """ + + @extend_schema( + tags=[GROUP_MEMBER_DATA_WITH_AGGREGATES_API_TAG], + summary='List group member data with aggregates.', + parameters=[serializers.SubsidyAccessPolicyAllocateRequestSerializer], + responses={ + status.HTTP_200_OK: serializers.GroupMemberWithAggregatesResponseSerializer, + status.HTTP_404_NOT_FOUND: None, + }, + ) + @action(detail=False, methods=['get']) + def get_group_member_data_with_aggregates(self, request, uuid): + """ + Retrieves Enterprise Group Members data zipped with subsidy aggregate enrollment data from a group record + linked to a subsidy access policy. + + Params: + group_uuid: (Optional) The Enterprise Group uuid from which to select members. Leave blank to fetch the + first group found in the PolicyGroupAssociation table associated with the supplied SubsidyAccessPolicy. + user_query: (Optional) Query sub-string to search/filter group members by email. + sort_by: (Optional) Choice- sort results by either: 'member_details', 'status', or 'recent_action'. + fetch_removed: (Optional) Whether or not to return deleted membership records. + is_reversed: (Optional) Reverse the order in which records are returned. + format_csv: (Optional) Whether or not to return data in a csv format, defaults to `False` + page: (Optional) Which page of Enterprise Group Membership records to request. Leave blank to fetch all + group membership records + """ + request_serializer = serializers.GroupMemberWithAggregatesRequestSerializer(data=request.query_params) + request_serializer.is_valid(raise_exception=True) + group_uuid = request_serializer.validated_data.get('group_uuid') + page = request_serializer.validated_data.get('page') + traverse_pagination = request_serializer.validated_data.get('traverse_pagination') + + try: + policy = SubsidyAccessPolicy.objects.get(uuid=uuid) + except SubsidyAccessPolicy.DoesNotExist: + return Response("Policy not found", status.HTTP_404_NOT_FOUND) + + # IMPLICITLY ASSUME there is exactly one group associated with the policy + # as of 04/2024 + if not group_uuid: + policy_group_association = PolicyGroupAssociation.objects.filter(subsidy_access_policy=policy.uuid).first() + if not policy_group_association: + return Response("Policy group not found associated with subsidy", status.HTTP_404_NOT_FOUND) + group_uuid = policy_group_association.enterprise_group_uuid + + # Request learner aggregate data from the subsidy service for this particular subsidy/policy + subsidy_learner_aggregate_dict = get_and_cache_subsidy_learners_aggregate_data( + policy.subsidy_uuid, + policy.uuid + ) + + # Request the group member data from platform + member_response = LmsApiClient().fetch_group_members( + group_uuid=group_uuid, + sort_by=request_serializer.validated_data.get('sort_by'), + user_query=request_serializer.validated_data.get('user_query'), + fetch_removed=request_serializer.validated_data.get('fetch_removed'), + is_reversed=request_serializer.validated_data.get('is_reversed'), + traverse_pagination=traverse_pagination, + page=page, + ) + member_results = member_response.get('results') + # Sift through the group members data, zipping the aggregate data from the subsidy service into + # each member record, assume enrollment count is 0 if subsidy enrollment aggregate data does not exist. + for key, result in enumerate(member_results): + enrollment_count = 0 + if lms_user_id := result.get('lms_user_id'): + enrollment_count = subsidy_learner_aggregate_dict.get(lms_user_id, 0) + result['enrollment_count'] = enrollment_count + member_results[key] = result + member_response['results'] = member_results + + # return in a csv format if indicated by query params + if request_serializer.validated_data.get('format_csv', False): + request.accepted_renderer = GroupMembersWithAggregatesCsvRenderer() + request.accepted_media_type = GroupMembersWithAggregatesCsvRenderer().media_type + return Response(list(member_results), status=status.HTTP_200_OK, content_type='text/csv') + + # Since we are essentially forwarding all request params to platform, we only need to replace the `next` and + # `previous` url values from the response returned by platform to construct a valid response object for the + # requester. + current_url = request.build_absolute_uri() + if not traverse_pagination: + if member_response.get('next'): + member_response['next'] = current_url.replace(f"page={page}", f"page={str(int(page) + 1)}") + if member_response.get('previous'): + member_response['previous'] = current_url.replace(f"page={page}", f"page={str(int(page) - 1)}") + else: + member_response['next'] = None + member_response['previous'] = None + return Response(data=member_response, status=200) diff --git a/enterprise_access/apps/api_client/exceptions.py b/enterprise_access/apps/api_client/exceptions.py new file mode 100644 index 00000000..f09dd730 --- /dev/null +++ b/enterprise_access/apps/api_client/exceptions.py @@ -0,0 +1,9 @@ +""" +Custom Exception classes for Enterprise Access API client. +""" + + +class FetchGroupMembersConflictingParamsException(Exception): + """ + An exception indicating that a subsidy request cannot be created. + """ diff --git a/enterprise_access/apps/api_client/lms_client.py b/enterprise_access/apps/api_client/lms_client.py index 063ffa3e..8637cb83 100644 --- a/enterprise_access/apps/api_client/lms_client.py +++ b/enterprise_access/apps/api_client/lms_client.py @@ -2,12 +2,14 @@ API client for calls to the LMS. """ import logging +import os import requests from django.conf import settings from rest_framework import status from enterprise_access.apps.api_client.base_oauth import BaseOAuthClient +from enterprise_access.apps.api_client.exceptions import FetchGroupMembersConflictingParamsException from enterprise_access.utils import should_send_email_to_pecu logger = logging.getLogger(__name__) @@ -23,6 +25,18 @@ class LmsApiClient(BaseOAuthClient): pending_enterprise_learner_endpoint = enterprise_api_base_url + 'pending-enterprise-learner/' enterprise_group_membership_endpoint = enterprise_api_base_url + 'enterprise-group/' + def enterprise_group_endpoint(self, group_uuid): + return os.path.join( + self.enterprise_api_base_url + 'enterprise-group/', + f"{group_uuid}/", + ) + + def enterprise_group_members_endpoint(self, group_uuid): + return os.path.join( + self.enterprise_group_endpoint(group_uuid), + "learners/", + ) + def get_enterprise_customer_data(self, enterprise_customer_uuid): """ Gets the data for an EnterpriseCustomer for the given uuid. @@ -115,6 +129,66 @@ def unlink_users_from_enterprise(self, enterprise_customer_uuid, user_emails, is logger.exception(msg, enterprise_customer_uuid) raise + def fetch_group_members( + self, + group_uuid, + sort_by=None, + user_query=None, + fetch_removed=False, + is_reversed=False, + traverse_pagination=False, + page=1, + ): + """ + Fetches enterprise group member records from edx-platform. + + Params: + - ``group_uuid`` (string, UUID): The group record PK to fetch members from. + - ``sort_by`` (string, optional): Specify how the returned members should be ordered. Supported sorting + values + are `member_details`, `member_status`, and `recent_action`. + - ``user_query`` (string, optional): Filter the returned members by user email with a provided sub-string. + - ``fetch_removed`` (bool, optional): Include removed membership records. + - ``is_reversed`` (bool, optional): Reverse the order of the returned members. + - ``traverse_pagination`` (bool, optional): Indicates that the lms client should traverse and fetch all + pages. + of data. Cannot be supplied if ``page`` is supplied. + - ``page`` (int, optional): Which page of paginated data to return. Cannot be supplied if + ``traverse_pagination`` is supplied. + """ + if traverse_pagination and page: + raise FetchGroupMembersConflictingParamsException( + 'Params `traverse_pagination` and `page` are in conflict, only supply one or the other' + ) + + group_members_url = self.enterprise_group_members_endpoint(group_uuid) + params = { + "sort_by": sort_by, + "user_query": user_query, + "fetch_removed": fetch_removed, + "page": page, + } + if is_reversed: + params['is_reversed'] = is_reversed + + response = self.client.get(group_members_url, params=params) + response.raise_for_status() + response_json = response.json() + results = response_json.get('results', []) + if traverse_pagination: + next_page = response.json().get("next") + while next_page: + response = self.client.get(next_page) + response.raise_for_status() + response_json = response.json() + next_page = response_json.get('next') + results.extend(response_json.get('results', [])) + + response_json['results'] = results + response_json['next'] = None + response_json['previous'] = None + return response_json + def get_enterprise_user(self, enterprise_customer_uuid, learner_id): """ Verify if `learner_id` is a part of an enterprise represented by `enterprise_customer_uuid`. @@ -209,7 +283,9 @@ def get_pending_enterprise_group_memberships(self, enterprise_group_uuid): A list of dicts of pecus in the form of that reminder emails should be sent to: { - 'pending_learner_id': integer, + 'enterprise_customer_user_id': integer, + 'lms_user_id': integer, + 'pending_enterprise_customer_user_id': integer, 'enterprise_group_membership_uuid': UUID, 'member_details': { 'user_email': string, @@ -228,7 +304,7 @@ def get_pending_enterprise_group_memberships(self, enterprise_group_uuid): resp_json = response.json() url = resp_json.get('next') for result in resp_json['results']: - pending_learner_id = result['pending_learner_id'] + pending_learner_id = result['pending_enterprise_customer_user_id'] recent_action = result['recent_action'] user_email = result['member_details']['user_email'] @@ -236,7 +312,7 @@ def get_pending_enterprise_group_memberships(self, enterprise_group_uuid): if should_send_email_to_pecu(recent_action_time): results.append({ - 'pending_learner_id': pending_learner_id, + 'pending_enterprise_customer_user_id': pending_learner_id, 'recent_action': recent_action, 'user_email': user_email, }) 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 66d7924a..7f6df280 100644 --- a/enterprise_access/apps/api_client/tests/test_lms_client.py +++ b/enterprise_access/apps/api_client/tests/test_lms_client.py @@ -250,6 +250,7 @@ def test_get_pending_enterprise_group_memberships(self, mock_oauth_client, mock_ mock_oauth_client.return_value.get.return_value.status_code = 200 enterprise_group_membership_uuid = uuid4() recent_action = datetime.strftime(datetime.today() - timedelta(days=5), '%B %d, %Y') + recent_action_no_reminder_needed = datetime.strftime(datetime.today() - timedelta(days=4), '%B %d, %Y') mock_json.return_value = { "next": None, "previous": None, @@ -259,7 +260,7 @@ def test_get_pending_enterprise_group_memberships(self, mock_oauth_client, mock_ "start": 0, "results": [ { - "pending_learner_id": 1, + "pending_enterprise_customer_user_id": 1, "enterprise_group_membership_uuid": enterprise_group_membership_uuid, "member_details": { "user_email": "test1@2u.com", @@ -269,26 +270,26 @@ def test_get_pending_enterprise_group_memberships(self, mock_oauth_client, mock_ "member_status": "pending", }, { - "pending_learner_id": 2, + "pending_enterprise_customer_user_id": 2, "enterprise_group_membership_uuid": enterprise_group_membership_uuid, "member_details": { "user_email": "test2@2u.com", "user_name": " " }, - "recent_action": "Invited: March 30, 2024", + "recent_action": f"Invited: {recent_action_no_reminder_needed}", "member_status": "pending", } ] } client = LmsApiClient() expected_return = [{ - "pending_learner_id": 1, + "pending_enterprise_customer_user_id": 1, "user_email": "test1@2u.com", "recent_action": f'Invited: {recent_action}'}] pending_enterprise_group_memberships = ( client.get_pending_enterprise_group_memberships(enterprise_group_membership_uuid)) mock_oauth_client.return_value.get.assert_called_with( - 'http://edx-platform.example.com/enterprise/api/v1/enterprise-group/' + + f'{settings.LMS_URL}/enterprise/api/v1/enterprise-group/' + f'{enterprise_group_membership_uuid}/learners/?pending_users_only=true', timeout=settings.LMS_CLIENT_TIMEOUT ) diff --git a/enterprise_access/apps/enterprise_groups/management/commands/tests/test_groups_reminder_emails.py b/enterprise_access/apps/enterprise_groups/management/commands/tests/test_groups_reminder_emails.py index a1864fbc..a7d3f7ba 100644 --- a/enterprise_access/apps/enterprise_groups/management/commands/tests/test_groups_reminder_emails.py +++ b/enterprise_access/apps/enterprise_groups/management/commands/tests/test_groups_reminder_emails.py @@ -63,14 +63,16 @@ def test_email_groups_command( } pending_group_memberships = [ { - "pending_learner_id": 1, + "enterprise_customer_user_id": None, + "lms_user_id": None, + "pending_enterprise_customer_user_id": 1, "enterprise_group_membership_uuid": self.enterprise_group_uuid, "recent_action": "Invited: March 25, 2024", "enterprise_customer": {"name": "Blk Dot Coffee"}, "user_email": "test1@2u.com", }, { - "pending_learner_id": 2, + "pending_enterprise_customer_user_id": 2, "enterprise_group_membership_uuid": self.enterprise_group_uuid, "recent_action": "Invited: March 30, 2024", "enterprise_customer": {"name": "Blk Dot Coffee"}, diff --git a/enterprise_access/apps/enterprise_groups/tests/test_tasks.py b/enterprise_access/apps/enterprise_groups/tests/test_tasks.py index f8493f89..4e522260 100644 --- a/enterprise_access/apps/enterprise_groups/tests/test_tasks.py +++ b/enterprise_access/apps/enterprise_groups/tests/test_tasks.py @@ -25,7 +25,7 @@ def setUp(self): self.recent_action = datetime.strftime(datetime.today() - timedelta(days=5), '%B %d, %Y') self.pending_enterprise_customer_users.append({ - "pending_learner_id": 1, + "pending_enterprise_customer_user_id": 1, "enterprise_group_membership_uuid": self.enterprise_group_membership_uuid, "user_email": "test1@2u.com", "recent_action": f'Invited: {self.recent_action}', diff --git a/enterprise_access/apps/subsidy_access_policy/subsidy_api.py b/enterprise_access/apps/subsidy_access_policy/subsidy_api.py index 8bc24eb1..3dc0152c 100644 --- a/enterprise_access/apps/subsidy_access_policy/subsidy_api.py +++ b/enterprise_access/apps/subsidy_access_policy/subsidy_api.py @@ -30,6 +30,38 @@ class TransactionPolicyMismatchError(Exception): """ +def subsidy_learner_aggregate_data_cache_key(subsidy_uuid, policy_uuid=None): + return versioned_cache_key('get_subsidy_learners_aggregate_data', subsidy_uuid, policy_uuid) + + +def get_and_cache_subsidy_learners_aggregate_data(subsidy_uuid, policy_uuid=None): + """ + Get aggregated learner data for a given subsidy. This can be optionally further filtered + """ + cache_key = subsidy_learner_aggregate_data_cache_key(subsidy_uuid, policy_uuid) + cached_response = TieredCache.get_cached_response(cache_key) + if cached_response.is_found: + logger.info( + f'subsidy_learners_aggregate_data cache hit for subsidy {subsidy_uuid} and policy {policy_uuid}' + ) + return cached_response.value + + client = get_versioned_subsidy_client(version=1) + try: + response_payload = client.get_subsidy_aggregates_by_learner_data( + subsidy_uuid, + policy_uuid, + ) + except requests.exceptions.HTTPError as exc: + raise SubsidyAPIHTTPError('HTTPError occurred in Subsidy API request.') from exc + + results = {} + for aggregated_data in response_payload: + results[aggregated_data.get('lms_user_id')] = aggregated_data.get('total') + TieredCache.set_all_tiers(cache_key, results, settings.SUBSIDY_AGGREGATES_CACHE_TIMEOUT) + return results + + def learner_transaction_cache_key(subsidy_uuid, lms_user_id): return versioned_cache_key('get_transactions_for_learner', subsidy_uuid, lms_user_id) diff --git a/enterprise_access/apps/subsidy_access_policy/utils.py b/enterprise_access/apps/subsidy_access_policy/utils.py index f53ab1a1..7867c897 100644 --- a/enterprise_access/apps/subsidy_access_policy/utils.py +++ b/enterprise_access/apps/subsidy_access_policy/utils.py @@ -17,14 +17,17 @@ } -def get_versioned_subsidy_client(): +def get_versioned_subsidy_client(version=None): """ Returns an instance of the enterprise subsidy client as the version specified by the Django setting `ENTERPRISE_SUBSIDY_API_CLIENT_VERSION`, if any. """ kwargs = {} - if getattr(settings, 'ENTERPRISE_SUBSIDY_API_CLIENT_VERSION', None): - kwargs['version'] = int(settings.ENTERPRISE_SUBSIDY_API_CLIENT_VERSION) + if not version: + if getattr(settings, 'ENTERPRISE_SUBSIDY_API_CLIENT_VERSION', None): + kwargs['version'] = int(settings.ENTERPRISE_SUBSIDY_API_CLIENT_VERSION) + else: + kwargs['version'] = int(version) return get_enterprise_subsidy_api_client(**kwargs) diff --git a/enterprise_access/settings/base.py b/enterprise_access/settings/base.py index 6063c275..4cb4ad75 100644 --- a/enterprise_access/settings/base.py +++ b/enterprise_access/settings/base.py @@ -497,6 +497,7 @@ def root(*path_fragments): # Cache timeouts SUBSIDY_RECORD_CACHE_TIMEOUT = 60 * 5 ENTERPRISE_USER_RECORD_CACHE_TIMEOUT = 60 * 5 +SUBSIDY_AGGREGATES_CACHE_TIMEOUT = 60 * 10 BRAZE_GROUPS_EMAIL_AUTO_REMINDER_DAY_5_CAMPAIGN = '' BRAZE_GROUPS_EMAIL_AUTO_REMINDER_DAY_25_CAMPAIGN = '' diff --git a/enterprise_access/settings/devstack.py b/enterprise_access/settings/devstack.py index 857a58e1..a605245c 100644 --- a/enterprise_access/settings/devstack.py +++ b/enterprise_access/settings/devstack.py @@ -28,12 +28,11 @@ # OAuth2 variables specific to social-auth/SSO login use case. SOCIAL_AUTH_EDX_OAUTH2_KEY = os.environ.get('SOCIAL_AUTH_EDX_OAUTH2_KEY', 'enterprise_access-sso-key') SOCIAL_AUTH_EDX_OAUTH2_SECRET = os.environ.get('SOCIAL_AUTH_EDX_OAUTH2_SECRET', 'enterprise_access-sso-secret') -SOCIAL_AUTH_EDX_OAUTH2_ISSUER = os.environ.get('SOCIAL_AUTH_EDX_OAUTH2_ISSUER', 'http://edx.devstack.lms:18000') +SOCIAL_AUTH_EDX_OAUTH2_ISSUER = os.environ.get('SOCIAL_AUTH_EDX_OAUTH2_ISSUER', 'http://localhost:18000') SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = os.environ.get('SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT', 'http://edx.devstack.lms:18000') -SOCIAL_AUTH_EDX_OAUTH2_LOGOUT_URL = os.environ.get( - 'SOCIAL_AUTH_EDX_OAUTH2_LOGOUT_URL', 'http://edx.devstack.lms:18000/logout') +SOCIAL_AUTH_EDX_OAUTH2_LOGOUT_URL = os.environ.get('SOCIAL_AUTH_EDX_OAUTH2_LOGOUT_URL', 'http://localhost:18000/logout') SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT = os.environ.get( - 'SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT', 'http://edx.devstack.lms:18000', + 'SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT', 'http://localhost:18000', ) # OAuth2 variables specific to backend service API calls. diff --git a/requirements/base.in b/requirements/base.in index 11d4bf15..ba086026 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -16,6 +16,7 @@ django-rest-swagger django-simple-history django-waffle djangorestframework +djangorestframework-csv fastavro edx-api-doc-tools edx-auth-backends diff --git a/requirements/base.txt b/requirements/base.txt index 5aae60f1..3ded3124 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -131,6 +131,7 @@ django-waffle==4.1.0 # edx-drf-extensions djangoql==0.18.1 # via -r requirements/base.in +djangorestframework-csv==3.0.2 djangorestframework==3.14.0 # via # -c requirements/constraints.txt @@ -167,7 +168,7 @@ edx-drf-extensions==10.3.0 # via # -r requirements/base.in # edx-rbac -edx-enterprise-subsidy-client==0.4.2 +edx-enterprise-subsidy-client==0.4.3 # via -r requirements/base.in edx-opaque-keys[django]==2.8.0 # via diff --git a/requirements/dev.txt b/requirements/dev.txt index 54fb485b..a13326b3 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -219,6 +219,7 @@ django-waffle==4.1.0 # edx-drf-extensions djangoql==0.18.1 # via -r requirements/validation.txt +djangorestframework-csv==3.0.2 djangorestframework==3.14.0 # via # -r requirements/validation.txt @@ -262,7 +263,7 @@ edx-drf-extensions==10.3.0 # via # -r requirements/validation.txt # edx-rbac -edx-enterprise-subsidy-client==0.4.2 +edx-enterprise-subsidy-client==0.4.3 # via -r requirements/validation.txt edx-i18n-tools==1.5.0 # via -r requirements/dev.in diff --git a/requirements/doc.txt b/requirements/doc.txt index 565349c4..bcfc386f 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -214,6 +214,7 @@ django-waffle==4.1.0 # edx-drf-extensions djangoql==0.18.1 # via -r requirements/test.txt +djangorestframework-csv==3.0.2 djangorestframework==3.14.0 # via # -c requirements/constraints.txt @@ -263,7 +264,7 @@ edx-drf-extensions==10.3.0 # via # -r requirements/test.txt # edx-rbac -edx-enterprise-subsidy-client==0.4.2 +edx-enterprise-subsidy-client==0.4.3 # via -r requirements/test.txt edx-lint==5.3.6 # via -r requirements/test.txt diff --git a/requirements/production.txt b/requirements/production.txt index feaaaeb7..c96db308 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -158,6 +158,7 @@ django-waffle==4.1.0 # edx-drf-extensions djangoql==0.18.1 # via -r requirements/base.txt +djangorestframework-csv==3.0.2 djangorestframework==3.14.0 # via # -r requirements/base.txt @@ -197,7 +198,7 @@ edx-drf-extensions==10.3.0 # via # -r requirements/base.txt # edx-rbac -edx-enterprise-subsidy-client==0.4.2 +edx-enterprise-subsidy-client==0.4.3 # via -r requirements/base.txt edx-opaque-keys[django]==2.8.0 # via diff --git a/requirements/quality.txt b/requirements/quality.txt index e7dd33e2..69b1224e 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -206,6 +206,7 @@ django-waffle==4.1.0 # edx-drf-extensions djangoql==0.18.1 # via -r requirements/test.txt +djangorestframework-csv==3.0.2 djangorestframework==3.14.0 # via # -c requirements/constraints.txt @@ -248,7 +249,7 @@ edx-drf-extensions==10.3.0 # via # -r requirements/test.txt # edx-rbac -edx-enterprise-subsidy-client==0.4.2 +edx-enterprise-subsidy-client==0.4.3 # via -r requirements/test.txt edx-lint==5.3.6 # via diff --git a/requirements/test.txt b/requirements/test.txt index 6f8a8e56..869072c8 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -190,6 +190,7 @@ django-waffle==4.1.0 # edx-drf-extensions djangoql==0.18.1 # via -r requirements/base.txt +djangorestframework-csv==3.0.2 djangorestframework==3.14.0 # via # -c requirements/constraints.txt @@ -230,7 +231,7 @@ edx-drf-extensions==10.3.0 # via # -r requirements/base.txt # edx-rbac -edx-enterprise-subsidy-client==0.4.2 +edx-enterprise-subsidy-client==0.4.3 # via -r requirements/base.txt edx-lint==5.3.6 # via -r requirements/test.in diff --git a/requirements/validation.txt b/requirements/validation.txt index e8f2fb35..f1e9623e 100644 --- a/requirements/validation.txt +++ b/requirements/validation.txt @@ -264,6 +264,7 @@ djangoql==0.18.1 # via # -r requirements/quality.txt # -r requirements/test.txt +djangorestframework-csv==3.0.2 djangorestframework==3.14.0 # via # -r requirements/quality.txt @@ -324,7 +325,7 @@ edx-drf-extensions==10.3.0 # -r requirements/quality.txt # -r requirements/test.txt # edx-rbac -edx-enterprise-subsidy-client==0.4.2 +edx-enterprise-subsidy-client==0.4.3 # via # -r requirements/quality.txt # -r requirements/test.txt