Skip to content

Commit

Permalink
feat: [assignments] backend support for "State" and "Recent action" c…
Browse files Browse the repository at this point in the history
…olumns

assignment serializer contains two additional keys:

* actions: A list of all serialized actions for the assignment.
* recent_action: dict, intended to power the "Recent action" column in the frontend display.

Assignment list views are now sortable on two keys:

* state: This is just the assignment internal state.
* recent_action_time: This is a dynamic field defined as `coalesce(most recent reminder, assignment creation)`

Example URLs
------------

Listing assignments sorted by state:
/api/v1/assignment-configurations/{assignment_configuration_uuid}/admin/assignments/?ordering=state
/api/v1/assignment-configurations/{assignment_configuration_uuid}/admin/assignments/?ordering=-state

Listing assignments sorted by recent_action_time:
/api/v1/assignment-configurations/{assignment_configuration_uuid}/admin/assignments/?ordering=recent_action_time
/api/v1/assignment-configurations/{assignment_configuration_uuid}/admin/assignments/?ordering=-recent_action_time

ENT-7780
  • Loading branch information
pwnage101 committed Oct 13, 2023
1 parent 8d3edb1 commit d46dee8
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 8 deletions.
13 changes: 13 additions & 0 deletions enterprise_access/apps/api/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
""" Constants for the api app. """
from enterprise_access.apps.content_assignments.constants import AssignmentActions

LICENSE_UNASSIGNED_STATUS = 'unassigned'


class RecentActionTypes(AssignmentActions):
"""
Types for recent_action.
"""
ASSIGNED = 'assigned'
REMINDED = 'reminded'
CHOICES = (
(ASSIGNED, 'Learner assigned content.'),
(REMINDED, 'Learner sent reminder message.'),
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,57 @@
"""
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.api.constants import RecentActionTypes
from enterprise_access.apps.content_assignments.constants import AssignmentActions, 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):
"""
"""
action_type = serializers.ChoiceField(choices=RecentActionTypes.CHOICES, help_text='Type of the recent action.')
timestamp = serializers.DateTimeField(help_text='Date and time when the action was taken.')


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,
)
recent_action = serializers.SerializerMethodField(
help_text='Structured data about the most recent action. Meant to power a frontend table column.',
)

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

@extend_schema_field(LearnerContentAssignmentRecentActionSerializer)
def get_recent_action(self, assignment):
"""
Return structured data about the most recent action, meant to power a frontend table column.
Recent actions can ONLY be one of:
* ``reminded`` actions.
* assignment record creation events.
These are not 1:1 with the actual AssignmentAction types.
"""
serializer_input = {}
reminded_actions = assignment.actions.filter(action_type=AssignmentActions.REMINDED)
if len(reminded_actions) > 0:
recent_action_type = RecentActionTypes.REMINDED
recent_action_time = reminded_actions.order_by('completed_at').last().completed_at
else:
# If there are no reminded actions on this assignment, fallback to the date this assignment was initially
# created.
recent_action_type = RecentActionTypes.ASSIGNED
recent_action_time = assignment.created

serializer_input = {
'action_type': recent_action_type,
'timestamp': recent_action_time,
}
return LearnerContentAssignmentRecentActionSerializer(serializer_input).data
22 changes: 22 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 @@ -9,6 +9,7 @@
from rest_framework import status
from rest_framework.reverse import reverse

from enterprise_access.apps.api.constants import RecentActionTypes
from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices
from enterprise_access.apps.content_assignments.tests.factories import (
AssignmentConfigurationFactory,
Expand Down Expand Up @@ -142,6 +143,11 @@ def test_allocate_happy_path(self, mock_allocate, mock_can_allocate):
'state': LearnerContentAssignmentStateChoices.ERRORED,
'transaction_uuid': None,
'uuid': str(self.alice_assignment.uuid),
'actions': [],
'recent_action': {
'action_type': RecentActionTypes.ASSIGNED,
'timestamp': self.alice_assignment.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
},
},
],
'created': [
Expand All @@ -155,6 +161,11 @@ def test_allocate_happy_path(self, mock_allocate, mock_can_allocate):
'state': LearnerContentAssignmentStateChoices.ALLOCATED,
'transaction_uuid': None,
'uuid': str(self.bob_assignment.uuid),
'actions': [],
'recent_action': {
'action_type': RecentActionTypes.ASSIGNED,
'timestamp': self.bob_assignment.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
},
},
],
'no_change': [
Expand All @@ -168,6 +179,11 @@ def test_allocate_happy_path(self, mock_allocate, mock_can_allocate):
'state': LearnerContentAssignmentStateChoices.ALLOCATED,
'transaction_uuid': None,
'uuid': str(self.carol_assignment.uuid),
'actions': [],
'recent_action': {
'action_type': RecentActionTypes.ASSIGNED,
'timestamp': self.carol_assignment.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
},
},
],
}
Expand Down Expand Up @@ -323,6 +339,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 +413,11 @@ def test_allocate_happy_path_e2e(
'state': LearnerContentAssignmentStateChoices.ALLOCATED,
'transaction_uuid': None,
'uuid': str(new_allocation.uuid),
'actions': [],
'recent_action': {
'action_type': RecentActionTypes.ASSIGNED,
'timestamp': new_allocation.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
},
},
],
'no_change': [],
Expand Down
85 changes: 84 additions & 1 deletion enterprise_access/apps/api/v1/tests/test_assignment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rest_framework import status
from rest_framework.reverse import reverse

from enterprise_access.apps.api.constants import RecentActionTypes
from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices
from enterprise_access.apps.content_assignments.models import LearnerContentAssignment
from enterprise_access.apps.content_assignments.tests.factories import (
Expand Down Expand Up @@ -91,6 +92,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 +103,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 +114,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 +125,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 +135,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 +146,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 +336,19 @@ 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': RecentActionTypes.ASSIGNED,
'timestamp': self.assignment_allocated_pre_link.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
},
}

@ddt.data(
Expand Down Expand Up @@ -351,6 +378,49 @@ 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 some reminder action to perturb the output ordering:
self.assignment_allocated_post_link.add_successful_reminded_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)

# Only the Assignments for the main customer is returned, and not that of the other customer.
expected_assignments_ordering = [
self.assignment_allocated_pre_link,
self.requester_assignment_accepted,
self.assignment_accepted,
self.requester_assignment_cancelled,
self.assignment_cancelled,
self.requester_assignment_errored,
# This assignment is knocked to the end of the list because we added a reminded action most recently.
self.assignment_allocated_post_link,
]
if not ordering_key or ordering_key.startswith('-'):
# The default ordering is reversed of chronological order.
expected_assignments_ordering.reverse()
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

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 +519,19 @@ 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')
],
'recent_action': {
'action_type': RecentActionTypes.ASSIGNED,
'timestamp': self.requester_assignment_accepted.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
},
}

def test_retrieve_other_assignment_not_found(self):
Expand Down Expand Up @@ -480,7 +563,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
Loading

0 comments on commit d46dee8

Please sign in to comment.