Skip to content

Commit

Permalink
Merge pull request #446 from openedx/asheehan-edx/ENT-8562-hydrated-g…
Browse files Browse the repository at this point in the history
…roup-members-data

feat: new subsidy access policy api endpoint to fetch group members data hydrated with aggregated enrollment count
  • Loading branch information
alex-sheehan-edx authored Apr 30, 2024
2 parents 31185ee + 6f1545f commit 4a0d9f4
Show file tree
Hide file tree
Showing 23 changed files with 549 additions and 27 deletions.
2 changes: 2 additions & 0 deletions enterprise_access/apps/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
AssignmentConfigurationUpdateRequestSerializer
)
from .subsidy_access_policy import (
GroupMemberWithAggregatesRequestSerializer,
GroupMemberWithAggregatesResponseSerializer,
SubsidyAccessPolicyAllocateRequestSerializer,
SubsidyAccessPolicyAllocationResponseSerializer,
SubsidyAccessPolicyCanRedeemElementResponseSerializer,
Expand Down
111 changes: 111 additions & 0 deletions enterprise_access/apps/api/serializers/subsidy_access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
10 changes: 9 additions & 1 deletion enterprise_access/apps/api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -27,4 +27,12 @@
'assignments',
)

urlpatterns = [
path(
'subsidy-access-policies/<uuid>/group-members',
views.SubsidyAccessPolicyGroupViewset.as_view({'get': 'get_group_member_data_with_aggregates'}),
name='aggregated-subsidy-enrollments'
),
]

urlpatterns += router.urls
1 change: 1 addition & 0 deletions enterprise_access/apps/api/v1/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .content_assignments.assignments_admin import LearnerContentAssignmentAdminViewSet
from .subsidy_access_policy import (
SubsidyAccessPolicyAllocateViewset,
SubsidyAccessPolicyGroupViewset,
SubsidyAccessPolicyRedeemViewset,
SubsidyAccessPolicyViewSet
)
Loading

0 comments on commit 4a0d9f4

Please sign in to comment.