Skip to content

Commit

Permalink
feat: cancel pending assignment functionality
Browse files Browse the repository at this point in the history
fix: refactored code based on reviewer feedback

fix: allow non-opaque-keys as content key for allocate requests

fix: data migration to delete Actions sans Assignments, and use on_delete=CASCADE (#314)

chore: allow optional logging of Django's SQL

fix: missing content titles on assignments

feat: Try to populate lms_user_id on assignments during creation

This is an attempt to cover the case where learners are already
logged in at the moment of assignment.  This case is a hole left by the
work in ENT-7875 which only set out to cover the case where a learner
was logged out at the moment of assignment.

ENT-7874

feat: Added assignments in credits_available endpoint

feat: allowing consumers of the learner content assignment api to filter by email and content title

fix: lint error

chore: refactored

fix: updated failing tests

feat: fetch enterprise catalog content metadata, serialize for credits_available assignments

ENT-7878

feat: add course_type field to assignment content metadata serializer

feat: return `learner_state_counts` as part of the admin assignments list API response (#322)

feat: validate requested allocation price

feat: link learners during assignment allocation

ENT-7778 | Call task to link learners to enterprise customer during allocation,
and have that ask add a successful linked action.

fix: new approach to generate `learner_state_count` in admin assignments list API; add addtl ordering and filtering support (#324)

chore: rebase

fix: lint error

chore: added test coverage
  • Loading branch information
katrinan029 committed Nov 13, 2023
1 parent bffc647 commit d6eccc0
Show file tree
Hide file tree
Showing 36 changed files with 1,599 additions and 64 deletions.
5 changes: 5 additions & 0 deletions enterprise_access/apps/api/filters/base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"""
Base FilterSet utility classes.
"""
from django_filters import BaseInFilter, CharFilter
from django_filters import rest_framework as drf_filters


class CharInFilter(BaseInFilter, CharFilter):
pass


class HelpfulFilterSet(drf_filters.FilterSet):
"""
Using an explicit FilterSet object works nicely with drf-spectacular
Expand Down
5 changes: 4 additions & 1 deletion enterprise_access/apps/api/filters/content_assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
API Filters for resources defined in the ``assignment_policy`` app.
"""
from ...content_assignments.models import AssignmentConfiguration, LearnerContentAssignment
from .base import HelpfulFilterSet
from .base import CharInFilter, HelpfulFilterSet


class AssignmentConfigurationFilter(HelpfulFilterSet):
Expand All @@ -18,11 +18,14 @@ class LearnerContentAssignmentAdminFilter(HelpfulFilterSet):
"""
Base filter for LearnerContentAssignment views.
"""
learner_state = CharInFilter(field_name='learner_state', lookup_expr='in')

class Meta:
model = LearnerContentAssignment
fields = [
'content_key',
'learner_email',
'lms_user_id',
'state',
'learner_state',
]
1 change: 1 addition & 0 deletions enterprise_access/apps/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
API serializers module.
"""
from .content_assignments.assignment import (
ContentMetadataForAssignmentSerializer,
LearnerContentAssignmentAdminResponseSerializer,
LearnerContentAssignmentResponseSerializer
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from rest_framework import serializers

from enterprise_access.apps.content_assignments.constants import (
AssignmentActions,
AssignmentLearnerStates,
AssignmentRecentActionTypes,
LearnerContentAssignmentStateChoices
Expand All @@ -24,6 +23,8 @@ class LearnerContentAssignmentActionSerializer(serializers.ModelSerializer):
class Meta:
model = LearnerContentAssignmentAction
fields = [
'created',
'modified',
'uuid',
'action_type',
'completed_at',
Expand Down Expand Up @@ -100,10 +101,130 @@ class LearnerContentAssignmentAdminResponseSerializer(LearnerContentAssignmentRe
),
choices=AssignmentLearnerStates.CHOICES,
)
error_reason = serializers.SerializerMethodField()

class Meta(LearnerContentAssignmentResponseSerializer.Meta):
fields = LearnerContentAssignmentResponseSerializer.Meta.fields + [
'recent_action',
'learner_state',
'error_reason',
]
read_only_fields = fields

@extend_schema_field(serializers.CharField)
def get_error_reason(self, assignment):
"""
Resolves the error reason for the assignment, if any, for display purposes based on
any associated actions.
"""
# If the assignment is not in an errored state, there should be no error reason.
if assignment.state != LearnerContentAssignmentStateChoices.ERRORED:
return None

# Assignment is an errored state, so determine the appropriate error reason so clients don't need to.
related_actions_with_error = assignment.actions.filter(error_reason__isnull=False).order_by('-created')
if not related_actions_with_error:
logger.warning(
'LearnerContentAssignment with UUID %s is in an errored state, but has no related '
'actions in an error state.',
assignment.uuid,
)
return None

# Get the most recently errored action.
return related_actions_with_error.first().error_reason


class CoursePartnerSerializer(serializers.Serializer):
"""
Serialized partner ``name`` and ``logo_image_url`` for content_metadata of an assignment.
"""
name = serializers.CharField(help_text='The partner name')
logo_image_url = serializers.CharField(help_text='The URL for the parter logo image')


class ContentMetadataForAssignmentSerializer(serializers.Serializer):
"""
Serializer to help return additional content metadata for assignments. These fields should
map more or less 1-1 to the fields in content metadata dicts returned from the
enterprise-catalog `get_content_metadata` response payload.
"""
start_date = serializers.SerializerMethodField(
help_text='The start date of the course',
)
end_date = serializers.SerializerMethodField(
help_text='The end date of the course',
)
enroll_by_date = serializers.SerializerMethodField(
help_text='The date by which the learner must accept/enroll',
)
content_price = serializers.SerializerMethodField(
help_text='The price, in USD, of this content',
)
course_type = serializers.CharField(
help_text='The type of course, something like "executive-education-2u" or "verified-audit"',
# Try to be a little defensive against malformed data.
required=False,
allow_null=True,
)
partners = serializers.SerializerMethodField()

@extend_schema_field(serializers.DateTimeField)
def get_start_date(self, obj):
return obj.get('normalized_metadata', {}).get('start_date')

@extend_schema_field(serializers.DateTimeField)
def get_end_date(self, obj):
return obj.get('normalized_metadata', {}).get('end_date')

@extend_schema_field(serializers.DateTimeField)
def get_enroll_by_date(self, obj):
return obj.get('normalized_metadata', {}).get('enroll_by_date')

@extend_schema_field(serializers.IntegerField)
def get_content_price(self, obj):
return obj.get('normalized_metadata', {}).get('content_price')

@extend_schema_field(CoursePartnerSerializer)
def get_partners(self, obj):
"""
See ``get_course_partners()`` in
enterprise-catalog/enterprise_catalog/apps/catalog/algolia_utils.py
"""
partners = []
owners = obj.get('owners') or []

for owner in owners:
partner_name = owner.get('name')
if partner_name:
partner_metadata = {
'name': partner_name,
'logo_image_url': owner.get('logo_image_url'),
}
partners.append(partner_metadata)

return CoursePartnerSerializer(partners, many=True).data


class LearnerContentAssignmentWithContentMetadataResponseSerializer(LearnerContentAssignmentResponseSerializer):
"""
Read-only serializer for LearnerContentAssignment records that also includes additional content metadata,
fetched from the catalog service (or cache).
"""
content_metadata = serializers.SerializerMethodField(
help_text='Additional content metadata fetched from the catalog service or cache.',
)

class Meta(LearnerContentAssignmentResponseSerializer.Meta):
fields = LearnerContentAssignmentResponseSerializer.Meta.fields + ['content_metadata']
read_only_fields = fields

@extend_schema_field(ContentMetadataForAssignmentSerializer)
def get_content_metadata(self, obj):
"""
Serializers content metadata for the assignment, if available.
"""
metadata_lookup = self.context.get('content_metadata')
if metadata_lookup and (assignment_content_metadata := metadata_lookup.get(obj.content_key)):
return ContentMetadataForAssignmentSerializer(assignment_content_metadata).data
return None
51 changes: 44 additions & 7 deletions enterprise_access/apps/api/serializers/subsidy_access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@
from requests.exceptions import HTTPError
from rest_framework import serializers

from enterprise_access.apps.content_assignments.api import get_content_metadata_for_assignments
from enterprise_access.apps.subsidy_access_policy.constants import CENTS_PER_DOLLAR, PolicyTypes
from enterprise_access.apps.subsidy_access_policy.models import SubsidyAccessPolicy

from .content_assignments.assignment import LearnerContentAssignmentResponseSerializer
from .content_assignments.assignment import (
LearnerContentAssignmentResponseSerializer,
LearnerContentAssignmentWithContentMetadataResponseSerializer
)
from .content_assignments.assignment_configuration import AssignmentConfigurationResponseSerializer

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -340,8 +344,14 @@ class SubsidyAccessPolicyCreditsAvailableRequestSerializer(serializers.Serialize
For view: SubsidyAccessPolicyRedeemViewset.credits_available
"""
enterprise_customer_uuid = serializers.UUIDField(required=True)
lms_user_id = serializers.IntegerField(required=True)
enterprise_customer_uuid = serializers.UUIDField(
required=True,
help_text='The customer for which available policies are filtered.',
)
lms_user_id = serializers.IntegerField(
required=True,
help_text='The user identifier for which available policies are filtered.',
)


# pylint: disable=abstract-method
Expand Down Expand Up @@ -519,17 +529,44 @@ class SubsidyAccessPolicyCreditsAvailableResponseSerializer(SubsidyAccessPolicyR
help_text='Remaining balance on the entire subsidy, in USD cents.',
)
subsidy_expiration_date = serializers.DateTimeField(
help_text='',
help_text='The date at which the related Subsidy record expires.',
source='subsidy_expiration_datetime',
)
learner_content_assignments = serializers.SerializerMethodField('get_assignments_serializer')

@extend_schema_field(LearnerContentAssignmentWithContentMetadataResponseSerializer)
def get_assignments_serializer(self, obj):
"""
Return serialized assignments if the policy access method is of the 'assigned' type
"""
if not obj.is_assignable:
return []

assignments = obj.assignment_configuration.assignments.prefetch_related('actions').filter(
lms_user_id=self.context.get('lms_user_id')
)
content_metadata_lookup = get_content_metadata_for_assignments(obj.catalog_uuid, assignments)
context = {'content_metadata': content_metadata_lookup}
serializer = LearnerContentAssignmentWithContentMetadataResponseSerializer(
assignments,
many=True,
context=context,
)
return serializer.data

@extend_schema_field(serializers.IntegerField)
def get_remaining_balance_per_user(self, obj):
lms_user_id = self.context.get('lms_user_id')
return obj.remaining_balance_per_user(lms_user_id=lms_user_id)
"""
The remaining balance per user for this policy, in USD cents, if applicable.
"""
if hasattr(obj, 'remaining_balance_per_user'):
lms_user_id = self.context.get('lms_user_id')
return obj.remaining_balance_per_user(lms_user_id=lms_user_id)
return None

@extend_schema_field(serializers.IntegerField)
def get_remaining_balance(self, obj):
"""Returns the remaining balance for the policy"""
return obj.subsidy_balance()


Expand Down Expand Up @@ -604,7 +641,7 @@ class SubsidyAccessPolicyAllocateRequestSerializer(serializers.Serializer):
allow_empty=False,
help_text='Learner emails to whom LearnerContentAssignments should be allocated.',
)
content_key = ContentKeyField(
content_key = serializers.CharField(
required=True,
help_text='Course content_key to which these learners are assigned.',
)
Expand Down
Loading

0 comments on commit d6eccc0

Please sign in to comment.