From 724529ca41ad0619d6b43dd65a5d130dcc9c369d Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan Date: Thu, 28 Mar 2024 18:35:21 +0000 Subject: [PATCH] feat: allowing for sorting and filtering of the enterprise group learner endpoints --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- enterprise/api/v1/serializers.py | 29 ++-- enterprise/api/v1/views/enterprise_group.py | 37 +++- enterprise/constants.py | 9 + .../management/commands/manufacture_data.py | 10 +- .../migrations/0206_auto_20240408_1344.py | 33 ++++ enterprise/models.py | 160 +++++++++++++++--- enterprise/utils.py | 7 + tests/test_enterprise/api/test_views.py | 152 ++++++++++++++++- tests/test_enterprise/test_signals.py | 3 + 11 files changed, 391 insertions(+), 55 deletions(-) create mode 100644 enterprise/migrations/0206_auto_20240408_1344.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ab73f25b51..edef324f71 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,10 @@ Change Log Unreleased ---------- +[4.15.1] +-------- +* feat: allowing for sorting and filtering of the enterprise group learner endpoints + [4.15.0] --------- * feat: Add new languages in enterprise customer admin diff --git a/enterprise/__init__.py b/enterprise/__init__.py index dff3a96b8f..bbd36b5529 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.15.0" +__version__ = "4.15.1" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index c95cbbce1a..a212b2278c 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -603,7 +603,7 @@ class EnterpriseGroupMembershipSerializer(serializers.ModelSerializer): member_details = serializers.SerializerMethodField() recent_action = serializers.SerializerMethodField() - member_status = serializers.SerializerMethodField() + status = serializers.CharField(required=False) class Meta: model = models.EnterpriseGroupMembership @@ -613,7 +613,7 @@ class Meta: 'enterprise_group_membership_uuid', 'member_details', 'recent_action', - 'member_status', + 'status', ) def get_member_details(self, obj): @@ -634,16 +634,6 @@ def get_recent_action(self, obj): return f"Accepted: {obj.activated_at.strftime('%B %d, %Y')}" return f"Invited: {obj.created.strftime('%B %d, %Y')}" - def get_member_status(self, obj): - """ - Return the status related to the membership. - """ - if obj.is_removed: - return "removed" - if obj.enterprise_customer_user: - return "accepted" - return "pending" - class EnterpriseCustomerUserReadOnlySerializer(serializers.ModelSerializer): """ @@ -1700,3 +1690,18 @@ class Meta: client_secret = serializers.CharField(read_only=True, default=generate_client_secret()) redirect_uris = serializers.CharField(required=False) updated = serializers.DateTimeField(required=False, read_only=True) + + +class EnterpriseGroupLearnersRequestQuerySerializer(serializers.Serializer): + """ + Serializer for the Enterprise Group Learners endpoint query filter + """ + 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, + ) diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py index 951aace252..e867c19ff5 100644 --- a/enterprise/api/v1/views/enterprise_group.py +++ b/enterprise/api/v1/views/enterprise_group.py @@ -12,7 +12,7 @@ from django.db.models import Q from django.http import Http404 -from enterprise import models, rules, utils +from enterprise import constants, models, rules, utils from enterprise.api.utils import get_enterprise_customer_from_enterprise_group_id from enterprise.api.v1 import serializers from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet @@ -101,6 +101,13 @@ def get_learners(self, *args, **kwargs): Request Arguments: - ``group_uuid`` (URL location, required): The uuid of the group from which learners should be listed. + Optional query params: + - ``q`` (string, optional): Filter the returned members by user email and name with a provided sub-string + - ``sort_by`` (string, optional): Specify how the returned members should be ordered. Supported sorting values + are `memberDetails`, `memberStatus`, and `recentAction`. Ordering can be reversed by supplying a `-` at the + beginning of the sorting value ie `-memberStatus`. + - ``page`` (int, optional): Which page of paginated data to return. + Returns: Paginated list of learners that are associated with the enterprise group uuid:: { @@ -117,11 +124,23 @@ def get_learners(self, *args, **kwargs): } """ + query_params = self.request.query_params.copy() + is_reversed = bool(query_params.get('is_reversed', False)) + + param_serializers = serializers.EnterpriseGroupLearnersRequestQuerySerializer( + data=query_params + ) + + if not param_serializers.is_valid(): + return Response(param_serializers.errors, status=400) + + user_query = param_serializers.validated_data.get('user_query') + sort_by = param_serializers.validated_data.get('sort_by') group_uuid = kwargs.get('group_uuid') try: group_object = self.get_queryset().get(uuid=group_uuid) - members = group_object.get_all_learners() + members = group_object.get_all_learners(user_query, sort_by, desc_order=is_reversed) page = self.paginate_queryset(members) serializer = serializers.EnterpriseGroupMembershipSerializer(page, many=True) response = self.get_paginated_response(serializer.data) @@ -197,6 +216,7 @@ def assign_learners(self, request, group_uuid): ent_customer_users = [ models.EnterpriseGroupMembership( activated_at=localized_utcnow(), + status=constants.GROUP_MEMBERSHIP_ACCEPTED_STATUS, enterprise_customer_user=ecu, group=group ) @@ -275,8 +295,15 @@ def remove_learners(self, request, group_uuid): ) records_deleted += len(records_to_delete) records_to_delete.delete() - data = { - 'records_deleted': records_deleted, - } + + # Woohoo! Records removed! Now to update the soft deleted records + deleted_records = models.EnterpriseGroupMembership.all_objects.filter( + group_q & (ecu_in_q | pecu_in_q), + ) + deleted_records.update( + status=constants.GROUP_MEMBERSHIP_REMOVED_STATUS, + removed_at=localized_utcnow() + ) + data = {'records_deleted': records_deleted} return Response(data, status=200) return Response(data="Error: missing request data: `learner_emails`.", status=400) diff --git a/enterprise/constants.py b/enterprise/constants.py index 5efb678957..4d20fff79a 100644 --- a/enterprise/constants.py +++ b/enterprise/constants.py @@ -241,3 +241,12 @@ class FulfillmentTypes: # The maximum length of a text field in the database. MAX_ALLOWED_TEXT_LENGTH = 16_000_000 + +GROUP_MEMBERSHIP_PENDING_STATUS = 'pending' +GROUP_MEMBERSHIP_REMOVED_STATUS = 'removed' +GROUP_MEMBERSHIP_ACCEPTED_STATUS = 'accepted' +GROUP_MEMBERSHIP_STATUS_CHOICES = ( + (GROUP_MEMBERSHIP_REMOVED_STATUS, 'Removed'), + (GROUP_MEMBERSHIP_ACCEPTED_STATUS, 'Accepted'), + (GROUP_MEMBERSHIP_PENDING_STATUS, 'Pending'), +) diff --git a/enterprise/management/commands/manufacture_data.py b/enterprise/management/commands/manufacture_data.py index f1ce5e3ae8..7f922a4565 100644 --- a/enterprise/management/commands/manufacture_data.py +++ b/enterprise/management/commands/manufacture_data.py @@ -3,7 +3,6 @@ """ import logging -import re import sys import factory @@ -13,6 +12,8 @@ from django.core.management.base import BaseCommand, CommandError, SystemCheckError, handle_default_options from django.db import connections +from enterprise.utils import convert_to_snake + # We have to import the enterprise test factories to ensure it's loaded and found by __subclasses__ # To ensure factories outside of the enterprise package are loaded and found by the script, # add any additionally desired factories as an import to this file. Make sure to catch the ImportError @@ -53,13 +54,6 @@ def all_subclasses(cls): [s for c in cls.__subclasses__() for s in all_subclasses(c)]) -def convert_to_snake(string): - """ - Helper method to convert strings to snake case. - """ - return re.sub(r'(?/learners/' + url = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': self.group_1.uuid}, + ) + # The problematic child + filter_query_param = "?user_query=Robert`); DROP TABLE enterprise_enterprisecustomeruser;--" + sql_injection_protected_response = self.client.get(url + filter_query_param) + assert sql_injection_protected_response.status_code == 200 + assert not sql_injection_protected_response.json().get('results') + assert EnterpriseCustomerUser.objects.all() + def test_successful_post_group(self): """ Test creating a new group record @@ -7701,6 +7841,8 @@ def test_successful_remove_learners_from_group(self): assert response.status_code == 200 assert response.data == {'records_deleted': 10} for membership in memberships_to_delete: + assert EnterpriseGroupMembership.all_objects.get(pk=membership.pk).status == 'removed' + assert EnterpriseGroupMembership.all_objects.get(pk=membership.pk).removed_at with self.assertRaises(EnterpriseGroupMembership.DoesNotExist): EnterpriseGroupMembership.objects.get(pk=membership.pk) diff --git a/tests/test_enterprise/test_signals.py b/tests/test_enterprise/test_signals.py index ef5e6c47ff..e26ae4aa55 100644 --- a/tests/test_enterprise/test_signals.py +++ b/tests/test_enterprise/test_signals.py @@ -261,6 +261,8 @@ def test_handle_user_post_save_fulfills_pending_group_memberships(self): enterprise_customer_user=None ) assert not new_membership.activated_at + assert new_membership.status == 'pending' + parameters = {"instance": user, "created": False} handle_user_post_save(mock.Mock(), **parameters) # Should delete pending link @@ -272,6 +274,7 @@ def test_handle_user_post_save_fulfills_pending_group_memberships(self): assert membership.pending_enterprise_customer_user is None assert membership.enterprise_customer_user == new_enterprise_user assert membership.activated_at + assert membership.status == 'accepted' @mark.django_db