Skip to content

Commit

Permalink
Merge pull request #288 from openedx/pwnage101/ENT-7693
Browse files Browse the repository at this point in the history
feat: serialize aggregates for policies to support budget details
  • Loading branch information
pwnage101 authored Oct 10, 2023
2 parents 4e35ec1 + 92ffb33 commit 8d3edb1
Show file tree
Hide file tree
Showing 5 changed files with 297 additions and 14 deletions.
87 changes: 79 additions & 8 deletions enterprise_access/apps/api/serializers/subsidy_access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
from django.apps import apps
from django.conf import settings
from django.urls import reverse
from drf_spectacular.utils import extend_schema_field
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import serializers

from enterprise_access.apps.subsidy_access_policy.constants import PolicyTypes
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
Expand Down Expand Up @@ -40,10 +41,73 @@ def policy_pre_write_validation(policy_instance_or_class, field_values_by_name):
)


# pylint: disable=abstract-method
class SubsidyAccessPolicyAggregatesSerializer(serializers.Serializer):
"""
Response serializer representing aggregates about the policy and related objects.
"""
amount_redeemed_usd_cents = serializers.SerializerMethodField(
help_text="Total Amount redeemed for policy, in positive USD cents.",
)
amount_redeemed_usd = serializers.SerializerMethodField(
help_text="Total Amount redeemed for policy, in USD.",
)
amount_allocated_usd_cents = serializers.SerializerMethodField(
help_text=(
f"Total amount allocated for policies of type {PolicyTypes.ASSIGNED_LEARNER_CREDIT} (0 otherwise), in "
"positive USD cents."
),
)
amount_allocated_usd = serializers.SerializerMethodField(
help_text=(
f"Total amount allocated for policies of type {PolicyTypes.ASSIGNED_LEARNER_CREDIT} (0 otherwise), in USD.",
),
)
spend_available_usd_cents = serializers.IntegerField(
help_text="Total Amount of available spend for policy, in positive USD cents.",
source="spend_available",
)
spend_available_usd = serializers.SerializerMethodField(
help_text="Total Amount of available spend for policy, in USD.",
)

@extend_schema_field(serializers.IntegerField)
def get_amount_redeemed_usd_cents(self, policy):
"""
Make amount a positive number.
"""
return policy.total_redeemed * -1

@extend_schema_field(serializers.IntegerField)
def get_amount_allocated_usd_cents(self, policy):
"""
Make amount a positive number.
"""
return policy.total_allocated * -1

@extend_schema_field(serializers.FloatField)
def get_amount_redeemed_usd(self, policy):
return float(policy.total_redeemed * -1) / CENTS_PER_DOLLAR

@extend_schema_field(serializers.FloatField)
def get_amount_allocated_usd(self, policy):
return float(policy.total_allocated * -1) / CENTS_PER_DOLLAR

@extend_schema_field(serializers.FloatField)
def get_spend_available_usd(self, policy):
return float(policy.spend_available) / CENTS_PER_DOLLAR


class SubsidyAccessPolicyResponseSerializer(serializers.ModelSerializer):
"""
A read-only Serializer for responding to requests for ``SubsidyAccessPolicy`` records.
"""
aggregates = SubsidyAccessPolicyAggregatesSerializer(
help_text='Aggregates about the policy and related objects.',
# This causes the entire unserialized model to be passed into the nested serializer.
source='*',
)

class Meta:
model = SubsidyAccessPolicy
fields = [
Expand All @@ -62,6 +126,7 @@ class Meta:
'subsidy_active_datetime',
'subsidy_expiration_datetime',
'is_subsidy_active',
'aggregates',
]
read_only_fields = fields

Expand Down Expand Up @@ -405,19 +470,25 @@ class SubsidyAccessPolicyCreditsAvailableResponseSerializer(SubsidyAccessPolicyR
For view: SubsidyAccessPolicyRedeemViewset.credits_available
"""
remaining_balance_per_user = serializers.SerializerMethodField()
remaining_balance = serializers.SerializerMethodField()
subsidy_expiration_date = serializers.SerializerMethodField()
remaining_balance_per_user = serializers.SerializerMethodField(
help_text='Remaining balance for the requesting user, in USD cents.',
)
remaining_balance = serializers.SerializerMethodField(
help_text='Remaining balance on the entire subsidy, in USD cents.',
)
subsidy_expiration_date = serializers.DateTimeField(
help_text='',
source='subsidy_expiration_datetime',
)

@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)

@extend_schema_field(serializers.IntegerField)
def get_remaining_balance(self, obj):
return obj.remaining_balance()

def get_subsidy_expiration_date(self, obj):
return obj.subsidy_expiration_datetime
return obj.subsidy_balance()


class SubsidyAccessPolicyCanRedeemReasonResponseSerializer(serializers.Serializer):
Expand Down
104 changes: 104 additions & 0 deletions enterprise_access/apps/api/tests/test_serializers.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,126 @@
"""
Tests for the serializers in the API.
"""
from datetime import datetime, timedelta
from unittest import mock
from uuid import uuid4

import ddt
from django.conf import settings
from django.test import TestCase
from django.urls import reverse

from enterprise_access.apps.api.serializers.subsidy_access_policy import (
SubsidyAccessPolicyAggregatesSerializer,
SubsidyAccessPolicyCreditsAvailableResponseSerializer,
SubsidyAccessPolicyRedeemableResponseSerializer
)
from enterprise_access.apps.content_assignments.tests.factories import (
AssignmentConfigurationFactory,
LearnerContentAssignmentFactory
)
from enterprise_access.apps.subsidy_access_policy.tests.factories import (
AssignedLearnerCreditAccessPolicyFactory,
PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory
)


@ddt.ddt
class TestSubsidyAccessPolicyResponseSerializer(TestCase):
"""
Tests for the SubsidyAccessPolicyResponseSerializer.
"""
@ddt.data(
# Test environment: An oddball zero value subsidy, no redemptions, and no allocations.
{'starting_balance': 0, 'spend_limit': 1, 'redeemed': 0, 'allocated': 0, 'available': 0}, # 000
# Test environment: 9 cent subsidy, oddball 0 cent policy.
# 4 possible cases, should always indicate no available spend.
{'starting_balance': 9, 'spend_limit': 0, 'redeemed': 0, 'allocated': 0, 'available': 0}, # 000
{'starting_balance': 9, 'spend_limit': 0, 'redeemed': 0, 'allocated': 1, 'available': 0}, # 010
{'starting_balance': 9, 'spend_limit': 0, 'redeemed': 1, 'allocated': 0, 'available': 0}, # 100
{'starting_balance': 9, 'spend_limit': 0, 'redeemed': 1, 'allocated': 1, 'available': 0}, # 110
# Test environment: 9 cent subsidy, unlimited policy.
# 7 possible cases, the sum of redeemed+allocated+available should always equal 9.
{'starting_balance': 9, 'spend_limit': 999, 'redeemed': 0, 'allocated': 0, 'available': 9}, # 001
{'starting_balance': 9, 'spend_limit': 999, 'redeemed': 0, 'allocated': 9, 'available': 0}, # 010
{'starting_balance': 9, 'spend_limit': 999, 'redeemed': 0, 'allocated': 5, 'available': 4}, # 011
{'starting_balance': 9, 'spend_limit': 999, 'redeemed': 9, 'allocated': 0, 'available': 0}, # 100
{'starting_balance': 9, 'spend_limit': 999, 'redeemed': 8, 'allocated': 0, 'available': 1}, # 101
{'starting_balance': 9, 'spend_limit': 999, 'redeemed': 5, 'allocated': 4, 'available': 0}, # 110
{'starting_balance': 9, 'spend_limit': 999, 'redeemed': 3, 'allocated': 3, 'available': 3}, # 111
# Test environment: 9 cent subsidy, 8 cent policy.
# 7 possible cases, the sum of redeemed+allocated+available should always equal 8.
{'starting_balance': 9, 'spend_limit': 8, 'redeemed': 0, 'allocated': 0, 'available': 8}, # 001
{'starting_balance': 9, 'spend_limit': 8, 'redeemed': 0, 'allocated': 8, 'available': 0}, # 010
{'starting_balance': 9, 'spend_limit': 8, 'redeemed': 0, 'allocated': 3, 'available': 5}, # 011
{'starting_balance': 9, 'spend_limit': 8, 'redeemed': 8, 'allocated': 0, 'available': 0}, # 100
{'starting_balance': 9, 'spend_limit': 8, 'redeemed': 7, 'allocated': 0, 'available': 1}, # 101
{'starting_balance': 9, 'spend_limit': 8, 'redeemed': 4, 'allocated': 4, 'available': 0}, # 110
{'starting_balance': 9, 'spend_limit': 8, 'redeemed': 3, 'allocated': 3, 'available': 2}, # 111
)
@ddt.unpack
@mock.patch('enterprise_access.apps.subsidy_access_policy.models.SubsidyAccessPolicy.subsidy_client')
def test_aggregates(
self,
mock_subsidy_client,
starting_balance,
spend_limit,
redeemed,
allocated,
available,
):
"""
Test that the policy aggregates serializer returns the correct aggregate values.
"""
test_enterprise_uuid = uuid4()

# Synthesize subsidy with the current_balance derived from ``starting_balance`` and ``redeemed``.
test_subsidy_uuid = uuid4()
mock_subsidy_client.retrieve_subsidy.return_value = {
'uuid': str(test_subsidy_uuid),
'enterprise_customer_uuid': str(test_enterprise_uuid),
'active_datetime': datetime.utcnow() - timedelta(days=1),
'expiration_datetime': datetime.utcnow() + timedelta(days=1),
'current_balance': starting_balance - redeemed,
'is_active': True,
}

# Create a test policy with a limit set to ``policy_spend_limit``. Reminder: a value of 0 means no limit.
assignment_configuration = AssignmentConfigurationFactory(
enterprise_customer_uuid=test_enterprise_uuid,
)
policy = AssignedLearnerCreditAccessPolicyFactory(
enterprise_customer_uuid=test_enterprise_uuid,
subsidy_uuid=test_subsidy_uuid,
spend_limit=spend_limit,
assignment_configuration=assignment_configuration,
active=True,
)

# Synthesize a number of 1 cent transactions equal to ``redeemed``.
mock_subsidy_client.list_subsidy_transactions.return_value = {
"results": [{"quantity": -1} for _ in range(redeemed)],
"aggregates": {"total_quantity": redeemed * -1},
}

# Synthesize a number of 1 cent assignments equal to ``allocated``.
for _ in range(allocated):
LearnerContentAssignmentFactory(
assignment_configuration=assignment_configuration,
content_quantity=-1,
)

serializer = SubsidyAccessPolicyAggregatesSerializer(policy)
data = serializer.data

assert data["amount_redeemed_usd_cents"] == redeemed
assert data["amount_allocated_usd_cents"] == allocated
assert data["spend_available_usd_cents"] == available


class TestSubsidyAccessPolicyRedeemableResponseSerializer(TestCase):
"""
Tests for the SubsidyAccessPolicyRedeemableResponseSerializer.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def setup_subsidy_mocks(self):
'id': 123455,
'active_datetime': self.yesterday,
'expiration_datetime': self.tomorrow,
'current_balance': 4,
'is_active': True,
}
subsidy_client_patcher = patch.object(
Expand Down Expand Up @@ -229,8 +230,13 @@ class TestAuthenticatedPolicyCRUDViews(CRUDViewTestMixin, APITestWithMocks):
"""

def setUp(self):
self.maxDiff = None
super().setUp()
super().setup_subsidy_mocks()
self.mock_subsidy_client.list_subsidy_transactions.return_value = {
"results": [{"quantity": -1}],
"aggregates": {"total_quantity": -1},
}

@ddt.data(
# A good admin role, but for a context/customer that doesn't match anything we're aware of, gets you a 403.
Expand Down Expand Up @@ -268,6 +274,14 @@ def test_detail_view(self, role_context_dict):
'subsidy_active_datetime': self.yesterday.isoformat(),
'subsidy_expiration_datetime': self.tomorrow.isoformat(),
'is_subsidy_active': True,
'aggregates': {
'amount_redeemed_usd_cents': 1,
'amount_redeemed_usd': 0.01,
'amount_allocated_usd_cents': 0,
'amount_allocated_usd': 0.00,
'spend_available_usd_cents': 2,
'spend_available_usd': 0.02,
}
}, response.json())

@ddt.data(
Expand Down Expand Up @@ -311,6 +325,14 @@ def test_list_view(self, role_context_dict):
'subsidy_active_datetime': self.yesterday.isoformat(),
'subsidy_expiration_datetime': self.tomorrow.isoformat(),
'is_subsidy_active': True,
'aggregates': {
'amount_redeemed_usd_cents': 1,
'amount_redeemed_usd': 0.01,
'amount_allocated_usd_cents': 0,
'amount_allocated_usd': 0.00,
'spend_available_usd_cents': 0,
'spend_available_usd': 0.00,
}
},
{
'access_method': 'direct',
Expand All @@ -328,6 +350,14 @@ def test_list_view(self, role_context_dict):
'subsidy_active_datetime': self.yesterday.isoformat(),
'subsidy_expiration_datetime': self.tomorrow.isoformat(),
'is_subsidy_active': True,
'aggregates': {
'amount_redeemed_usd_cents': 1,
'amount_redeemed_usd': 0.01,
'amount_allocated_usd_cents': 0,
'amount_allocated_usd': 0.00,
'spend_available_usd_cents': 2,
'spend_available_usd': 0.02,
}
},
]

Expand Down Expand Up @@ -388,6 +418,14 @@ def test_destroy_view(self, request_payload, expected_change_reason):
'subsidy_active_datetime': self.yesterday.isoformat(),
'subsidy_expiration_datetime': self.tomorrow.isoformat(),
'is_subsidy_active': True,
'aggregates': {
'amount_redeemed_usd_cents': 1,
'amount_redeemed_usd': 0.01,
'amount_allocated_usd_cents': 0,
'amount_allocated_usd': 0.00,
'spend_available_usd_cents': 2,
'spend_available_usd': 0.02,
}
}
self.assertEqual(expected_response, response.json())

Expand Down Expand Up @@ -458,6 +496,14 @@ def test_update_views(self, is_patch):
'subsidy_active_datetime': self.yesterday.isoformat(),
'subsidy_expiration_datetime': self.tomorrow.isoformat(),
'is_subsidy_active': True,
'aggregates': {
'amount_redeemed_usd_cents': 1,
'amount_redeemed_usd': 0.01,
'amount_allocated_usd_cents': 0,
'amount_allocated_usd': 0.00,
'spend_available_usd_cents': 4,
'spend_available_usd': 0.04,
}
}
self.assertEqual(expected_response, response.json())

Expand Down
2 changes: 2 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ class PolicyTypes:

PER_LEARNER_ENROLLMENT_CREDIT = 'PerLearnerEnrollmentCreditAccessPolicy'
PER_LEARNER_SPEND_CREDIT = 'PerLearnerSpendCreditAccessPolicy'
ASSIGNED_LEARNER_CREDIT = 'AssignedLearnerCreditAccessPolicy'

CHOICES = (
(PER_LEARNER_ENROLLMENT_CREDIT, PER_LEARNER_ENROLLMENT_CREDIT),
(PER_LEARNER_SPEND_CREDIT, PER_LEARNER_SPEND_CREDIT),
(ASSIGNED_LEARNER_CREDIT, ASSIGNED_LEARNER_CREDIT),
)


Expand Down
Loading

0 comments on commit 8d3edb1

Please sign in to comment.