Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: can_redeem checks for enrollment deadline #538

Merged
merged 1 commit into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
SYSTEM_ENTERPRISE_OPERATOR_ROLE
)
from enterprise_access.apps.subsidy_access_policy.constants import (
REASON_BEYOND_ENROLLMENT_DEADLINE,
REASON_LEARNER_ASSIGNMENT_CANCELLED,
REASON_LEARNER_ASSIGNMENT_FAILED,
REASON_LEARNER_NOT_ASSIGNED_CONTENT,
Expand Down Expand Up @@ -2202,6 +2203,57 @@ def test_can_redeem_policy_no_price(self, mock_lms_client, mock_transactions_cac
'detail': f'Could not determine price for content_key: {test_content_key}',
}

@mock.patch('enterprise_access.apps.subsidy_access_policy.subsidy_api.get_and_cache_transactions_for_learner')
@mock.patch('enterprise_access.apps.api.v1.views.subsidy_access_policy.LmsApiClient')
def test_can_redeem_policy_beyond_enrollment_deadline(self, mock_lms_client, mock_transactions_cache_for_learner):
"""
Test that the can_redeem endpoint successfully serializes a response for content whose
enrollment deadline has passed.
"""
test_content_key = "course-v1:demox+1234+2T2023"
mock_lms_client.return_value.get_enterprise_customer_data.return_value = {
'slug': 'sluggy',
'admin_users': [{'email': 'edx@example.org'}],
}

self.mock_get_content_metadata.return_value = {
"content_price": 1234,
"enroll_by_date": '2001-01-01T00:00:00Z',
}

mock_transactions_cache_for_learner.return_value = {
'transactions': [],
'aggregates': {
'total_quantity': 0,
},
}

mocked_content_data_from_view = {
"content_uuid": str(uuid4()),
"content_key": test_content_key,
"source": "edX",
"content_price": 1234,
"enroll_by_date": '2001-01-01T00:00:00Z',
}

with mock.patch(
'enterprise_access.apps.subsidy_access_policy.content_metadata_api.get_and_cache_content_metadata',
return_value=mocked_content_data_from_view,
):
query_params = {'content_key': test_content_key}
response = self.client.get(self.subsidy_access_policy_can_redeem_endpoint, query_params)

assert response.status_code == status.HTTP_200_OK
response_list = response.json()
assert response_list[0]["reasons"] == [
{
"reason": REASON_BEYOND_ENROLLMENT_DEADLINE,
"user_message": MissingSubsidyAccessReasonUserMessages.BEYOND_ENROLLMENT_DEADLINE,
"metadata": mock.ANY,
"policy_uuids": [str(self.redeemable_policy.uuid)],
},
]

@mock.patch('enterprise_access.apps.subsidy_access_policy.subsidy_api.get_versioned_subsidy_client')
def test_can_redeem_subsidy_client_http_error(self, mock_get_client):
"""
Expand Down
4 changes: 3 additions & 1 deletion enterprise_access/apps/api/v1/views/subsidy_access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from enterprise_access.apps.events.utils import send_subsidy_redemption_event_to_event_bus
from enterprise_access.apps.subsidy_access_policy.constants import (
GROUP_MEMBERS_WITH_AGGREGATES_DEFAULT_PAGE_SIZE,
REASON_BEYOND_ENROLLMENT_DEADLINE,
REASON_CONTENT_NOT_IN_CATALOG,
REASON_LEARNER_ASSIGNMENT_CANCELLED,
REASON_LEARNER_ASSIGNMENT_FAILED,
Expand Down Expand Up @@ -224,6 +225,7 @@ def _get_user_message_for_reason(reason_slug, enterprise_admin_users):
REASON_LEARNER_MAX_SPEND_REACHED: MissingSubsidyAccessReasonUserMessages.LEARNER_LIMITS_REACHED,
REASON_LEARNER_MAX_ENROLLMENTS_REACHED: MissingSubsidyAccessReasonUserMessages.LEARNER_LIMITS_REACHED,
REASON_CONTENT_NOT_IN_CATALOG: MissingSubsidyAccessReasonUserMessages.CONTENT_NOT_IN_CATALOG,
REASON_BEYOND_ENROLLMENT_DEADLINE: MissingSubsidyAccessReasonUserMessages.BEYOND_ENROLLMENT_DEADLINE,
REASON_LEARNER_NOT_ASSIGNED_CONTENT: MissingSubsidyAccessReasonUserMessages.LEARNER_NOT_ASSIGNED_CONTENT,
REASON_LEARNER_ASSIGNMENT_CANCELLED: MissingSubsidyAccessReasonUserMessages.LEARNER_ASSIGNMENT_CANCELED,
REASON_LEARNER_ASSIGNMENT_FAILED: MissingSubsidyAccessReasonUserMessages.LEARNER_NOT_ASSIGNED_CONTENT,
Expand Down Expand Up @@ -768,7 +770,7 @@ def can_redeem(self, request, enterprise_customer_uuid):
enterprise_customer_uuid, lms_user_id, content_key
)

if not redemptions and not redeemable_policies:
if not successful_redemptions and not redeemable_policies:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In local testing, I noticed I wasn't getting any reasons in my test responses even when I can an expected can_redeem=False result. I tend to use reversals a lot to set up test state, so I have many unsuccessful redemptions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems reasonable!

reasons.extend(_get_reasons_for_no_redeemable_policies(
enterprise_customer_uuid,
non_redeemable_policies
Expand Down
3 changes: 3 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ class MissingSubsidyAccessReasonUserMessages:
LEARNER_LIMITS_REACHED = "You can't enroll right now because of limits set by your organization."
CONTENT_NOT_IN_CATALOG = \
"You can't enroll right now because this course is no longer available in your organization's catalog."
BEYOND_ENROLLMENT_DEADLINE = \
"You can't enroll right now because the enrollment deadline for this course has passed."
LEARNER_NOT_IN_ENTERPRISE = \
"You can't enroll right now because your account is no longer associated with the organization."
LEARNER_NOT_ASSIGNED_CONTENT = \
Expand All @@ -104,6 +106,7 @@ class MissingSubsidyAccessReasonUserMessages:
REASON_POLICY_EXPIRED = "policy_expired"
REASON_SUBSIDY_EXPIRED = "subsidy_expired"
REASON_CONTENT_NOT_IN_CATALOG = "content_not_in_catalog"
REASON_BEYOND_ENROLLMENT_DEADLINE = "beyond_enrollment_deadline"
REASON_LEARNER_NOT_IN_ENTERPRISE = "learner_not_in_enterprise"
REASON_LEARNER_NOT_IN_ENTERPRISE_GROUP = "learner_not_in_enterprise_group"
REASON_NOT_ENOUGH_VALUE_IN_SUBSIDY = "not_enough_value_in_subsidy"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import requests
from django.conf import settings
from django.utils.dateparse import parse_datetime
from edx_django_utils.cache import TieredCache
from requests.exceptions import HTTPError

Expand Down Expand Up @@ -125,3 +126,13 @@ def list_price_dict_from_usd_cents(list_price_integer_cents):
"usd": list_price_decimal_dollars,
"usd_cents": list_price_integer_cents,
}


def enroll_by_datetime(content_metadata):
"""
Helper to return a datetime object representing
the enrollment deadline for a content_metdata record.
"""
if enroll_by_date := content_metadata.get('enroll_by_date'):
return parse_datetime(enroll_by_date)
return None
46 changes: 39 additions & 7 deletions enterprise_access/apps/subsidy_access_policy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from .constants import (
CREDIT_POLICY_TYPE_PRIORITY,
FORCE_ENROLLMENT_KEYWORD,
REASON_BEYOND_ENROLLMENT_DEADLINE,
REASON_CONTENT_NOT_IN_CATALOG,
REASON_LEARNER_ASSIGNMENT_CANCELLED,
REASON_LEARNER_ASSIGNMENT_EXPIRED,
Expand All @@ -46,6 +47,7 @@
TransactionStateChoices
)
from .content_metadata_api import (
enroll_by_datetime,
get_and_cache_catalog_contains_content,
get_and_cache_content_metadata,
get_list_price_for_content,
Expand Down Expand Up @@ -686,7 +688,11 @@ def _log_redeemability(self, is_redeemable, reason, lms_user_id, content_key, ex
)
logger.info(message, self.uuid, is_redeemable, reason, lms_user_id, content_key, extra)

def can_redeem(self, lms_user_id, content_key, skip_customer_user_check=False):
def can_redeem(
self, lms_user_id, content_key,
skip_customer_user_check=False, skip_enrollment_deadline_check=False,
**kwargs,
):
"""
Check that a given learner can redeem the given content.
The ordering of each conditional is intentional based on an expected
Expand Down Expand Up @@ -732,7 +738,13 @@ def can_redeem(self, lms_user_id, content_key, skip_customer_user_check=False):
self._log_redeemability(False, REASON_CONTENT_NOT_IN_CATALOG, lms_user_id, content_key)
return (False, REASON_CONTENT_NOT_IN_CATALOG, [])

# TODO: Add Course Upgrade/Registration Deadline Passed Error here
# Check if the current time is beyond the enrollment deadline for the content,
# but only if late redemption is *not* currently allowed.
if not skip_enrollment_deadline_check and not self.is_late_redemption_allowed:
enrollment_deadline = enroll_by_datetime(content_metadata)
if enrollment_deadline and (timezone.now() > enrollment_deadline):
self._log_redeemability(False, REASON_BEYOND_ENROLLMENT_DEADLINE, lms_user_id, content_key)
return (False, REASON_BEYOND_ENROLLMENT_DEADLINE, [])

# We want to wait to do these checks that might require a call
# to the enterprise-subsidy service until we *know* we'll need the data.
Expand Down Expand Up @@ -1115,14 +1127,22 @@ class Meta:
"""
proxy = True

def can_redeem(self, lms_user_id, content_key, skip_customer_user_check=False):
def can_redeem(
self, lms_user_id, content_key,
skip_customer_user_check=False, skip_enrollment_deadline_check=False,
**kwargs,
):
"""
Checks if the given lms_user_id has a number of existing subsidy transactions
LTE to the learner enrollment cap declared by this policy.
"""
# perform generic access checks
should_attempt_redemption, reason, existing_redemptions = \
super().can_redeem(lms_user_id, content_key, skip_customer_user_check)
super().can_redeem(
lms_user_id, content_key,
skip_customer_user_check, skip_enrollment_deadline_check,
**kwargs,
)
if not should_attempt_redemption:
return (False, reason, existing_redemptions)

Expand Down Expand Up @@ -1185,7 +1205,11 @@ class Meta:
"""
proxy = True

def can_redeem(self, lms_user_id, content_key, skip_customer_user_check=False):
def can_redeem(
self, lms_user_id, content_key,
skip_customer_user_check=False, skip_enrollment_deadline_check=False,
**kwargs,
):
"""
Determines whether learner can redeem a subsidy access policy given the
limits specified on the policy.
Expand All @@ -1195,6 +1219,8 @@ def can_redeem(self, lms_user_id, content_key, skip_customer_user_check=False):
lms_user_id,
content_key,
skip_customer_user_check,
skip_enrollment_deadline_check,
**kwargs,
)
if not should_attempt_redemption:
self._log_redeemability(False, reason, lms_user_id, content_key, extra=existing_redemptions)
Expand Down Expand Up @@ -1356,7 +1382,11 @@ def get_list_price(self, lms_user_id, content_key):
# that value has been consumed from a subsidy (though not necessarily fulfilled)
return list_price_dict_from_usd_cents(found_assignment.content_quantity * -1)

def can_redeem(self, lms_user_id, content_key, skip_customer_user_check=False):
def can_redeem(
self, lms_user_id, content_key,
skip_customer_user_check=False, skip_enrollment_deadline_check=False,
**kwargs,
):
"""
Checks if the given lms_user_id has an existing assignment on the given content_key, ready to be accepted.
"""
Expand All @@ -1365,6 +1395,8 @@ def can_redeem(self, lms_user_id, content_key, skip_customer_user_check=False):
lms_user_id,
content_key,
skip_customer_user_check,
skip_enrollment_deadline_check,
**kwargs,
)
if not should_attempt_redemption:
self._log_redeemability(False, reason, lms_user_id, content_key, extra=existing_redemptions)
Expand Down Expand Up @@ -1731,7 +1763,7 @@ def force_redeem(self, extra_metadata=None):
try:
with self.subsidy_access_policy.lock():
can_redeem, reason, existing_transactions = self.subsidy_access_policy.can_redeem(
self.lms_user_id, self.course_run_key,
self.lms_user_id, self.course_run_key, skip_enrollment_deadline_check=True,
)
extra_metadata = extra_metadata or {}
if can_redeem:
Expand Down
Loading
Loading