Skip to content

Commit

Permalink
Merge pull request #292 from openedx/pwnage101/ENT-7780
Browse files Browse the repository at this point in the history
feat: [assignments] backend support for "State" and "Recent action" columns
  • Loading branch information
pwnage101 authored Oct 18, 2023
2 parents 6df70c9 + 8fcf408 commit e7f7035
Show file tree
Hide file tree
Showing 8 changed files with 412 additions and 17 deletions.
5 changes: 4 additions & 1 deletion enterprise_access/apps/api/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""
API serializers module.
"""
from .content_assignments.assignment import LearnerContentAssignmentResponseSerializer
from .content_assignments.assignment import (
LearnerContentAssignmentAdminResponseSerializer,
LearnerContentAssignmentResponseSerializer
)
from .content_assignments.assignment_configuration import (
AssignmentConfigurationCreateRequestSerializer,
AssignmentConfigurationDeleteRequestSerializer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,66 @@
"""
import logging

from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers

from enterprise_access.apps.content_assignments.models import LearnerContentAssignment
from enterprise_access.apps.content_assignments.constants import (
AssignmentActions,
AssignmentLearnerStates,
AssignmentRecentActionTypes,
LearnerContentAssignmentStateChoices
)
from enterprise_access.apps.content_assignments.models import LearnerContentAssignment, LearnerContentAssignmentAction

logger = logging.getLogger(__name__)


class LearnerContentAssignmentActionSerializer(serializers.ModelSerializer):
"""
A read-only Serializer for responding to requests for ``LearnerContentAssignmentAction`` records.
"""
class Meta:
model = LearnerContentAssignmentAction
fields = [
'uuid',
'action_type',
'completed_at',
'error_reason',

# Intentionally hide traceback from response, since this is primarily for developers/on-call and could
# over-communicate secrets.
# 'traceback',
]
read_only_fields = fields


class LearnerContentAssignmentRecentActionSerializer(serializers.Serializer):
"""
Structured data about the most recent action, meant to power a frontend table column.
"""
action_type = serializers.ChoiceField(
help_text='Type of the recent action.',
choices=AssignmentRecentActionTypes.CHOICES,
source='recent_action',
)
timestamp = serializers.DateTimeField(
help_text='Date and time when the action was taken.',
source='recent_action_time',
)


class LearnerContentAssignmentResponseSerializer(serializers.ModelSerializer):
"""
A read-only Serializer for responding to requests for ``LearnerContentAssignment`` records.
"""
# This causes the related AssignmentConfiguration to be serialized as a UUID (in the response).
assignment_configuration = serializers.PrimaryKeyRelatedField(read_only=True)

actions = LearnerContentAssignmentActionSerializer(
help_text='All actions associated with this assignment.',
many=True,
)

class Meta:
model = LearnerContentAssignment
fields = [
Expand All @@ -29,5 +75,34 @@ class Meta:
'state',
'transaction_uuid',
'last_notification_at',
'actions',
]
read_only_fields = fields


class LearnerContentAssignmentAdminResponseSerializer(LearnerContentAssignmentResponseSerializer):
"""
A read-only Serializer for responding to requests for ``LearnerContentAssignment`` records FOR ADMINS.
Important: This serializer depends on extra dynamic fields annotated by calling
``LearnerContentAssignment.annotate_dynamic_fields_onto_queryset()`` on the assignment queryset.
"""

recent_action = LearnerContentAssignmentRecentActionSerializer(
help_text='Structured data about the most recent action. Meant to power a frontend table column.',
source='*',
)
learner_state = serializers.ChoiceField(
help_text=(
'learner_state is an admin-facing dynamic state, not to be confused with `state`. Meant to power a '
'frontend table column.'
),
choices=AssignmentLearnerStates.CHOICES,
)

class Meta(LearnerContentAssignmentResponseSerializer.Meta):
fields = LearnerContentAssignmentResponseSerializer.Meta.fields + [
'recent_action',
'learner_state',
]
read_only_fields = fields
5 changes: 5 additions & 0 deletions enterprise_access/apps/api/v1/tests/test_allocation_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def test_allocate_happy_path(self, mock_allocate, mock_can_allocate):
'state': LearnerContentAssignmentStateChoices.ERRORED,
'transaction_uuid': None,
'uuid': str(self.alice_assignment.uuid),
'actions': [],
},
],
'created': [
Expand All @@ -155,6 +156,7 @@ def test_allocate_happy_path(self, mock_allocate, mock_can_allocate):
'state': LearnerContentAssignmentStateChoices.ALLOCATED,
'transaction_uuid': None,
'uuid': str(self.bob_assignment.uuid),
'actions': [],
},
],
'no_change': [
Expand All @@ -168,6 +170,7 @@ def test_allocate_happy_path(self, mock_allocate, mock_can_allocate):
'state': LearnerContentAssignmentStateChoices.ALLOCATED,
'transaction_uuid': None,
'uuid': str(self.carol_assignment.uuid),
'actions': [],
},
],
}
Expand Down Expand Up @@ -323,6 +326,7 @@ def setUpTestData(cls):

def setUp(self):
super().setUp()
self.maxDiff = None

self.enterprise_uuid = OTHER_TEST_ENTERPRISE_UUID

Expand Down Expand Up @@ -396,6 +400,7 @@ def test_allocate_happy_path_e2e(
'state': LearnerContentAssignmentStateChoices.ALLOCATED,
'transaction_uuid': None,
'uuid': str(new_allocation.uuid),
'actions': [],
},
],
'no_change': [],
Expand Down
148 changes: 146 additions & 2 deletions enterprise_access/apps/api/v1/tests/test_assignment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
from rest_framework import status
from rest_framework.reverse import reverse

from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices
from enterprise_access.apps.content_assignments.constants import (
AssignmentLearnerStates,
AssignmentRecentActionTypes,
LearnerContentAssignmentStateChoices
)
from enterprise_access.apps.content_assignments.models import LearnerContentAssignment
from enterprise_access.apps.content_assignments.tests.factories import (
AssignmentConfigurationFactory,
Expand Down Expand Up @@ -91,6 +95,8 @@ def setUp(self):
transaction_uuid=None,
assignment_configuration=self.assignment_configuration,
)
self.assignment_allocated_post_link.add_successful_linked_action()
self.assignment_allocated_post_link.add_successful_notified_action()

# This assignment has been accepted by the learner (state=accepted), AND the assigned learner is the requester.
self.requester_assignment_accepted = LearnerContentAssignmentFactory(
Expand All @@ -100,6 +106,8 @@ def setUp(self):
transaction_uuid=uuid4(),
assignment_configuration=self.assignment_configuration,
)
self.requester_assignment_accepted.add_successful_linked_action()
self.requester_assignment_accepted.add_successful_notified_action()

# This assignment has been accepted by the learner (state=accepted), AND the assigned learner is not the
# requester.
Expand All @@ -109,6 +117,8 @@ def setUp(self):
transaction_uuid=uuid4(),
assignment_configuration=self.assignment_configuration,
)
self.assignment_accepted.add_successful_linked_action()
self.assignment_accepted.add_successful_notified_action()

# This assignment has been cancelled (state=cancelled), AND the assigned learner is the requester.
self.requester_assignment_cancelled = LearnerContentAssignmentFactory(
Expand All @@ -118,6 +128,8 @@ def setUp(self):
transaction_uuid=uuid4(),
assignment_configuration=self.assignment_configuration,
)
self.requester_assignment_cancelled.add_successful_linked_action()
self.requester_assignment_cancelled.add_successful_notified_action()

# This assignment has been cancelled (state=cancelled), AND the assigned learner is not the requester.
self.assignment_cancelled = LearnerContentAssignmentFactory(
Expand All @@ -126,6 +138,8 @@ def setUp(self):
transaction_uuid=uuid4(),
assignment_configuration=self.assignment_configuration,
)
self.assignment_cancelled.add_successful_linked_action()
self.assignment_cancelled.add_successful_notified_action()

# This assignment encountered a system error (state=errored), AND the assigned learner is the requester.
self.requester_assignment_errored = LearnerContentAssignmentFactory(
Expand All @@ -135,6 +149,9 @@ def setUp(self):
transaction_uuid=uuid4(),
assignment_configuration=self.assignment_configuration,
)
linked_action, _ = self.assignment_cancelled.add_successful_linked_action()
linked_action.error_reason = 'Phony error reason.'
linked_action.save()

###
# Below are additional assignments pertaining to a completely different customer than the main test customer.
Expand Down Expand Up @@ -322,6 +339,20 @@ def test_retrieve(self, role_context_dict):
'lms_user_id': None,
'state': LearnerContentAssignmentStateChoices.ALLOCATED,
'transaction_uuid': self.assignment_allocated_pre_link.transaction_uuid,
'actions': [
{
'uuid': str(action.uuid),
'action_type': action.action_type,
'completed_at': action.completed_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'error_reason': None,
}
for action in self.assignment_allocated_pre_link.actions.order_by('completed_at')
],
'recent_action': {
'action_type': AssignmentRecentActionTypes.ASSIGNED,
'timestamp': self.assignment_allocated_pre_link.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
},
'learner_state': AssignmentLearnerStates.NOTIFYING,
}

@ddt.data(
Expand Down Expand Up @@ -351,6 +382,110 @@ def test_list(self, role_context_dict):
actual_assignment_uuids = {UUID(assignment['uuid']) for assignment in response.json()['results']}
assert actual_assignment_uuids == expected_assignment_uuids

@ddt.data(
None,
'recent_action_time',
'-recent_action_time',
)
def test_list_ordering_recent_action_time(self, ordering_key):
"""
Test that the list view returns objects in the correct order when recent_action_time is the ordering key. Also
check that when no ordering parameter is supplied, the default ordering uses recent_action_time.
"""
self.set_jwt_cookie([{
'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE,
'context': str(TEST_ENTERPRISE_UUID),
}])

# Add reminder action to perturb the output ordering:
self.assignment_allocated_post_link.add_successful_reminded_action()

# Add non-reminder actions to another assignment to make sure it does NOT perturb the output ordering.
self.assignment_allocated_pre_link.add_successful_linked_action()
self.assignment_allocated_pre_link.add_successful_notified_action()

query_params = None
if ordering_key:
query_params = {'ordering': ordering_key}

# Send a list request for all Assignments for the main test customer, optionally with a specific ordering.
response = self.client.get(ADMIN_ASSIGNMENTS_LIST_ENDPOINT, data=query_params)

# Explicitly define the REVERSE order of assignments returned by the list view. This should be the order if
# ?ordering=+recent_action_time
recent_action_time_ordering = [
# First 6 assignments oredered by their creation time.
self.assignment_allocated_pre_link, # Still chronologically first, despite recent non-reminder actions.
self.requester_assignment_accepted,
self.assignment_accepted,
self.requester_assignment_cancelled,
self.assignment_cancelled,
self.requester_assignment_errored,
# This assignment was created first, but is knocked to the end of the list because we added a reminded
# action most recently.
self.assignment_allocated_post_link,
]
expected_assignments_ordering = None
if not ordering_key or ordering_key.startswith('-'):
# The default ordering is reversed of chronological order.
expected_assignments_ordering = reversed(recent_action_time_ordering)
else:
# Ordering is chronological IFF ?ordering=recent_action_time
expected_assignments_ordering = recent_action_time_ordering
expected_assignment_uuids = [assignment.uuid for assignment in expected_assignments_ordering]
actual_assignment_uuids = [UUID(assignment['uuid']) for assignment in response.json()['results']]
assert actual_assignment_uuids == expected_assignment_uuids

@ddt.data(
'learner_state_sort_order',
'-learner_state_sort_order',
)
def test_list_ordering_learner_state_sort_order(self, ordering_key):
"""
Test that the list view returns objects in the correct order when learner_state_sort_order is the ordering key.
"""
self.set_jwt_cookie([{
'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE,
'context': str(TEST_ENTERPRISE_UUID),
}])

query_params = None
if ordering_key:
query_params = {'ordering': ordering_key}

# Send a list request for all Assignments for the main test customer, optionally with a specific ordering.
response = self.client.get(ADMIN_ASSIGNMENTS_LIST_ENDPOINT, data=query_params)

list_response_ordering = [
# First, list allocated, non-notified assignments.
self.assignment_allocated_pre_link,
# Then, list allocated, notified assignments.
self.assignment_allocated_post_link,
# Then, list errored assignments.
self.requester_assignment_errored,

# No need to test sort order of accepted and cancelled assignments since they are not displayed.
# self.assignment_accepted,
# self.requester_assignment_accepted,
# self.requester_assignment_cancelled,
# self.assignment_cancelled,
]
expected_assignments_ordering = list_response_ordering
if ordering_key.startswith('-'):
# The default ordering is reversed of chronological order.
expected_assignments_ordering = reversed(list_response_ordering)
expected_assignment_uuids = [assignment.uuid for assignment in expected_assignments_ordering]
actual_assignment_uuids = [
UUID(assignment['uuid'])
for assignment in response.json()['results']
# Only gather the assignments with the states under test from the response.
if assignment['state'] in (
LearnerContentAssignmentStateChoices.ALLOCATED,
LearnerContentAssignmentStateChoices.ERRORED,
)
]
assert actual_assignment_uuids == expected_assignment_uuids

def test_cancel(self):
"""
Test that the cancel view cancels the assignment and returns an appropriate response with 200 status code and
Expand Down Expand Up @@ -449,6 +584,15 @@ def test_retrieve(self, role_context_dict):
'lms_user_id': self.requester_assignment_accepted.lms_user_id,
'state': LearnerContentAssignmentStateChoices.ACCEPTED,
'transaction_uuid': str(self.requester_assignment_accepted.transaction_uuid),
'actions': [
{
'uuid': str(action.uuid),
'action_type': action.action_type,
'completed_at': str(action.completed_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ')),
'error_reason': None,
}
for action in self.requester_assignment_accepted.actions.order_by('completed_at')
],
}

def test_retrieve_other_assignment_not_found(self):
Expand Down Expand Up @@ -480,7 +624,7 @@ def test_retrieve_other_assignment_not_found(self):
)
def test_list(self, role_context_dict):
"""
Test that the list view returns a 200 response code and the expected (list) results of serialization..
Test that the list view returns a 200 response code and the expected (list) results of serialization.
This also tests that only Assignments for the requesting user are returned.
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ def get_queryset(self):
"""
A base queryset to list or retrieve ``LearnerContentAssignment`` records. In this viewset, only the assignments
assigned to the requester are returned.
Unlike in LearnerContentAssignmentAdminViewSet, here we are not going to annotate the extra dynamic fields using
`annotate_dynamic_fields_onto_queryset()`, so we will NOT serialize `learner_state` and `recent_action` for each
assignment.
"""
return LearnerContentAssignment.objects.filter(
learner_email=self.requesting_user_email,
Expand Down
Loading

0 comments on commit e7f7035

Please sign in to comment.