Skip to content

Commit

Permalink
Merge branch 'main' into johnnagro/ENT-7848/0
Browse files Browse the repository at this point in the history
  • Loading branch information
johnnagro authored Oct 20, 2023
2 parents 51ceba0 + 43f7680 commit cfb97b2
Show file tree
Hide file tree
Showing 5 changed files with 304 additions and 24 deletions.
106 changes: 101 additions & 5 deletions enterprise_access/apps/content_assignments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
from typing import Iterable

from django.db.models import Sum
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator

from enterprise_access.apps.subsidy_access_policy.content_metadata_api import get_and_cache_content_metadata

Expand Down Expand Up @@ -52,15 +55,33 @@ def get_assignments_for_configuration(
return queryset


def get_assignments_by_learner_email_and_content(
def get_assignments_for_admin(
assignment_configuration,
learner_emails,
content_key,
):
"""
Returns a queryset of all ``LearnerContentAssignment`` records
in the given assignment configuration for the provided list
of learner_emails and the given content_key.
Get any existing allocations relevant to an enterprise admin's allocation request.
Method of content_key comparison for assignment lookup:
+---+------------------------+-----------------------+--------------------+
| # | assignment content_key | requested content_key | How to compare? |
+---+------------------------+-----------------------+--------------------+
| 1 | course | course | Simple comparison. |
| 2 | course | course run | Not supported. |
| 3 | course run | course | Not supported. |
| 4 | course run | course run | Not supported. |
+---+------------------------+-----------------------+--------------------+
Args:
assignment_configuration (AssignmentConfiguration):
The assignment configuration within which to search for assignments.
learner_emails (list of str): A list of emails for which the admin intends to find existing assignments.
content_key (str): A content key representing a course which the assignments are for.
Returns:
queryset of ``LearnerContentAssignment``: Existing records relevant to an admin's allocation request.
"""
return get_assignments_for_configuration(
assignment_configuration,
Expand All @@ -69,6 +90,81 @@ def get_assignments_by_learner_email_and_content(
)


def _get_course_key_from_locator(course_locator: CourseLocator) -> str:
"""
Given a CourseLocator, construct a course key.
"""
return f'{course_locator.org}+{course_locator.course}'


def _normalize_course_key(course_key_str: str) -> str:
"""
Given a course key string without without a namespace prefix, construct a course key without one. This matches what
we expect to always be stored in assignments.
"""
if course_key_str.startswith(CourseLocator.CANONICAL_NAMESPACE):
return course_key_str[len(CourseLocator.CANONICAL_NAMESPACE) + 1:]
else:
return course_key_str


def get_assignment_for_learner(
assignment_configuration,
lms_user_id,
content_key,
):
"""
Get any existing allocations relevant to a learner's redemption request.
There's no guarantee that the given `content_key` and the assignment object's `content_key` are both course keys or
both course run keys, so a simple string comparison may not always suffice. Method of content_key comparison for
assignment lookup:
+---+------------------------+-----------------------+----------------------------------------------+
| # | assignment content_key | requested content_key | How to compare? |
+---+------------------------+-----------------------+----------------------------------------------+
| 1 | course | course | Simple comparison. |
| 2 | course | course run | Convert everything to courses, then compare. | (most common)
| 3 | course run | course | Not supported. |
| 4 | course run | course run | Not supported. |
+---+------------------------+-----------------------+----------------------------------------------+
Args:
assignment_configuration (AssignmentConfiguration):
The assignment configuration within which to search for assignments.
lms_user_id (int): One lms_user_id which the assignments are for.
content_key (str): A content key representing a course which the assignments are for.
Returns:
``LearnerContentAssignment``: Existing assignment relevant to a learner's redemption request, or None if not
found.
Raises:
``django.core.exceptions.MultipleObjectsReturned``: This should be impossible because of a db-level uniqueness
constraint across [assignment_configuration,lms_user_id,content_key]. BUT still technically possible if
internal staff managed to create a duplicate assignment configuration for a single enterprise.
"""
content_key_to_match = None
# Whatever the requested content_key is, normalize it to a course with no namespace prefix.
try:
requested_course_run_locator = CourseKey.from_string(content_key)
# No exception raised, content_key represents a course run, so convert it to a course.
content_key_to_match = _get_course_key_from_locator(requested_course_run_locator)
except InvalidKeyError:
# Either the key was already a course key (no problem), or it was something else (weird).
content_key_to_match = _normalize_course_key(content_key)
queryset = LearnerContentAssignment.objects.select_related('assignment_configuration')
try:
return queryset.get(
assignment_configuration=assignment_configuration,
lms_user_id=lms_user_id,
# assignment content_key is assumed to always be a course with no namespace prefix.
content_key=content_key_to_match,
)
except LearnerContentAssignment.DoesNotExist:
return None


def get_allocated_quantity_for_configuration(assignment_configuration):
"""
Returns a float representing the total quantity, in USD cents, currently allocated
Expand Down Expand Up @@ -123,7 +219,7 @@ def allocate_assignments(assignment_configuration, learner_emails, content_key,
content_quantity = content_price_cents * -1

# Fetch any existing assignments for all pairs of (learner, content) in this assignment config.
existing_assignments = get_assignments_by_learner_email_and_content(
existing_assignments = get_assignments_for_admin(
assignment_configuration,
learner_emails,
content_key,
Expand Down
81 changes: 81 additions & 0 deletions enterprise_access/apps/content_assignments/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@
"""
from unittest import mock

import ddt
from django.test import TestCase

from ..api import (
AllocationException,
allocate_assignments,
cancel_assignments,
get_allocated_quantity_for_configuration,
get_assignment_for_learner,
get_assignments_for_configuration
)
from ..constants import LearnerContentAssignmentStateChoices
from ..models import AssignmentConfiguration
from .factories import LearnerContentAssignmentFactory


@ddt.ddt
class TestContentAssignmentApi(TestCase):
"""
Tests functions of the ``content_assignment.api`` module.
Expand All @@ -26,6 +29,7 @@ class TestContentAssignmentApi(TestCase):
def setUpClass(cls):
super().setUpClass()
cls.assignment_configuration = AssignmentConfiguration.objects.create()
cls.other_assignment_configuration = AssignmentConfiguration.objects.create()

def test_get_assignments_for_configuration(self):
"""
Expand Down Expand Up @@ -84,6 +88,83 @@ def test_get_assignments_for_configuration_different_states(self):
sorted(expected_assignments[filter_state], key=lambda record: record.uuid),
)

@ddt.data(
# Standard happy path.
{
'assignment_content_key': 'test+course',
'assignment_lms_user_id': 1,
'request_default_assignment_configuration': True,
'request_lms_user_id': 1,
'request_content_key': 'course-v1:test+course+run',
'expect_assignment_found': True,
},
# Happy path, requested content is a course (with prefix).
{
'assignment_content_key': 'test+course',
'assignment_lms_user_id': 1,
'request_default_assignment_configuration': True,
'request_lms_user_id': 1,
'request_content_key': 'course-v1:test+course', # This is a course! With a prefix!
'expect_assignment_found': True,
},
# Happy path, requested content is a course (without prefix).
{
'assignment_content_key': 'test+course',
'assignment_lms_user_id': 1,
'request_default_assignment_configuration': True,
'request_lms_user_id': 1,
'request_content_key': 'test+course', # This is a course! Without a prefix!
'expect_assignment_found': True,
},
# Different lms_user_id.
{
'assignment_content_key': 'test+course',
'assignment_lms_user_id': 1,
'request_default_assignment_configuration': True,
'request_lms_user_id': 2, # Different lms_user_id!
'request_content_key': 'test+course',
'expect_assignment_found': False,
},
# Different customer.
{
'assignment_content_key': 'test+course',
'assignment_lms_user_id': 1,
'request_default_assignment_configuration': False, # Different customer!
'request_lms_user_id': 1,
'request_content_key': 'test+course',
'expect_assignment_found': False,
},
)
@ddt.unpack
def test_get_assignment_for_learner(
self,
assignment_content_key,
assignment_lms_user_id,
request_default_assignment_configuration,
request_lms_user_id,
request_content_key,
expect_assignment_found,
):
"""
Test get_assignment_for_learner().
"""
LearnerContentAssignmentFactory.create(
assignment_configuration=self.assignment_configuration,
content_key=assignment_content_key,
lms_user_id=assignment_lms_user_id,
state=LearnerContentAssignmentStateChoices.ALLOCATED,
)
actual_assignment = get_assignment_for_learner(
(
self.assignment_configuration
if request_default_assignment_configuration
else self.other_assignment_configuration
),
request_lms_user_id,
request_content_key,
)
assert (actual_assignment is not None) == expect_assignment_found

def test_get_allocated_quantity_for_configuration(self):
"""
Tests to verify that we can fetch the total allocated quantity across a set of assignments
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 @@ -105,3 +105,6 @@ class MissingSubsidyAccessReasonUserMessages:
REASON_LEARNER_MAX_SPEND_REACHED = "learner_max_spend_reached"
REASON_POLICY_SPEND_LIMIT_REACHED = "policy_spend_limit_reached"
REASON_LEARNER_MAX_ENROLLMENTS_REACHED = "learner_max_enrollments_reached"
REASON_LEARNER_NOT_ASSIGNED_CONTENT = "reason_learner_not_assigned_content"
REASON_LEARNER_ASSIGNMENT_CANCELLED = "reason_learner_assignment_cancelled"
REASON_LEARNER_ASSIGNMENT_FAILED = "reason_learner_assignment_failed"
36 changes: 35 additions & 1 deletion enterprise_access/apps/subsidy_access_policy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@

from enterprise_access.apps.api_client.lms_client import LmsApiClient
from enterprise_access.apps.content_assignments import api as assignments_api
from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices
from enterprise_access.utils import is_none, is_not_none

from ..content_assignments.models import AssignmentConfiguration
from .constants import (
CREDIT_POLICY_TYPE_PRIORITY,
REASON_CONTENT_NOT_IN_CATALOG,
REASON_LEARNER_ASSIGNMENT_CANCELLED,
REASON_LEARNER_ASSIGNMENT_FAILED,
REASON_LEARNER_MAX_ENROLLMENTS_REACHED,
REASON_LEARNER_MAX_SPEND_REACHED,
REASON_LEARNER_NOT_ASSIGNED_CONTENT,
REASON_LEARNER_NOT_IN_ENTERPRISE,
REASON_NOT_ENOUGH_VALUE_IN_SUBSIDY,
REASON_POLICY_EXPIRED,
Expand Down Expand Up @@ -1011,7 +1015,37 @@ def spend_available(self):
return max(0, super().spend_available + self.total_allocated)

def can_redeem(self, lms_user_id, content_key, skip_customer_user_check=False):
raise NotImplementedError
"""
Checks if the given lms_user_id has an existing assignment on the given content_key, ready to be accepted.
"""
# perform generic access checks
should_attempt_redemption, reason, existing_redemptions = super().can_redeem(
lms_user_id,
content_key,
skip_customer_user_check,
)
if not should_attempt_redemption:
return (False, reason, existing_redemptions)
# Now that the default checks are complete, proceed with the custom checks for this assignment policy type.
found_assignment = assignments_api.get_assignment_for_learner(
self.assignment_configuration,
lms_user_id,
content_key,
)
if not found_assignment:
return (False, REASON_LEARNER_NOT_ASSIGNED_CONTENT, existing_redemptions)
elif found_assignment.state == LearnerContentAssignmentStateChoices.CANCELLED:
return (False, REASON_LEARNER_ASSIGNMENT_CANCELLED, existing_redemptions)
elif found_assignment.state == LearnerContentAssignmentStateChoices.ERRORED:
return (False, REASON_LEARNER_ASSIGNMENT_FAILED, existing_redemptions)
elif found_assignment.state == LearnerContentAssignmentStateChoices.ACCEPTED:
# This should never happen. Even if the frontend had a bug that called the redemption endpoint for already
# redeemed content, we already check for existing redemptions at the beginning of this function and fail
# fast. Reaching this block would be extremely weird.
return (False, REASON_LEARNER_NOT_ASSIGNED_CONTENT, existing_redemptions)

# Learner can redeem the subsidy access policy
return (True, None, existing_redemptions)

def redeem(self, lms_user_id, content_key, all_transactions, metadata=None):
raise NotImplementedError
Expand Down
Loading

0 comments on commit cfb97b2

Please sign in to comment.