Skip to content

Commit

Permalink
feat: Make reversal writing more DRY, and gate signal handler behind …
Browse files Browse the repository at this point in the history
…feature flag
  • Loading branch information
pwnage101 committed Jan 7, 2025
1 parent 6e99c1e commit 1589455
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 172 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
Transaction Reversals where appropriate.
"""
import logging
from datetime import datetime, timedelta

from django.conf import settings
from django.contrib import auth
Expand All @@ -14,6 +13,7 @@
from enterprise_subsidy.apps.api_client.enterprise import EnterpriseApiClient
from enterprise_subsidy.apps.content_metadata.api import ContentMetadataApi
from enterprise_subsidy.apps.transaction.api import cancel_transaction_external_fulfillment, reverse_transaction
from enterprise_subsidy.apps.transaction.utils import unenrollment_can_be_refunded

logger = logging.getLogger(__name__)
User = auth.get_user_model()
Expand Down Expand Up @@ -52,67 +52,6 @@ def add_arguments(self, parser):
),
)

def convert_unenrollment_datetime_string(self, datetime_str):
"""
Helper method to strip microseconds from a datetime object
"""
try:
formatted_datetime = datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%SZ")
except ValueError:
formatted_datetime = datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S.%fZ")
return formatted_datetime

def unenrollment_can_be_refunded(
self,
content_metadata,
enterprise_course_enrollment,
):
"""
helper method to determine if an unenrollment is refundable
"""
# Retrieve the course start date from the content metadata
enrollment_course_run_key = enterprise_course_enrollment.get("course_id")
enrollment_created_at = enterprise_course_enrollment.get("created")
course_start_date = None
if content_metadata.get('content_type') == 'courserun':
course_start_date = content_metadata.get('start')
else:
for run in content_metadata.get('course_runs', []):
if run.get('key') == enrollment_course_run_key:
course_start_date = run.get('start')
break

if not course_start_date:
logger.warning(
f"No course start date found for course run: {enrollment_course_run_key}. "
"Unable to determine refundability."
)
return False

# https://2u-internal.atlassian.net/browse/ENT-6825
# OCM course refundability is defined as True IFF:
# ie MAX(enterprise enrollment created at, course start date) + 14 days > unenrolled_at date
enrollment_created_at = enterprise_course_enrollment.get("created")
enrollment_unenrolled_at = enterprise_course_enrollment.get("unenrolled_at")

enrollment_created_datetime = self.convert_unenrollment_datetime_string(enrollment_created_at)
course_start_datetime = self.convert_unenrollment_datetime_string(course_start_date)
enrollment_unenrolled_at_datetime = self.convert_unenrollment_datetime_string(enrollment_unenrolled_at)
refund_cutoff_date = max(course_start_datetime, enrollment_created_datetime) + timedelta(days=14)

if refund_cutoff_date > enrollment_unenrolled_at_datetime:
logger.info(
f"Course run: {enrollment_course_run_key} is refundable for enterprise customer user: "
f"{enterprise_course_enrollment.get('enterprise_customer_user')}. Writing Reversal record."
)
return True
else:
logger.info(
f"Unenrollment from course: {enrollment_course_run_key} by user: "
f"{enterprise_course_enrollment.get('enterprise_customer_user')} is not refundable."
)
return False

def handle_reversing_enterprise_course_unenrollment(self, unenrollment):
"""
Helper method to determine refund eligibility of unenrollments and generating reversals for enterprise course
Expand Down Expand Up @@ -168,7 +107,7 @@ def handle_reversing_enterprise_course_unenrollment(self, unenrollment):
content_metadata = self.fetched_content_metadata.get(enrollment_course_run_key)

# Check if the OCM unenrollment is refundable
if not self.unenrollment_can_be_refunded(content_metadata, enterprise_course_enrollment):
if not unenrollment_can_be_refunded(content_metadata, enterprise_course_enrollment):
logger.info(
f"{self.dry_run_prefix}Unenrollment from course: {enrollment_course_run_key} by user: "
f"{enterprise_course_enrollment.get('enterprise_customer_user')} is not refundable."
Expand Down
54 changes: 7 additions & 47 deletions enterprise_subsidy/apps/transaction/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
↳ Updates any assignments as needed.
"""
import logging
from datetime import datetime, timedelta

from django.conf import settings
from django.dispatch import receiver
Expand All @@ -49,6 +48,7 @@
reverse_transaction
)
from enterprise_subsidy.apps.transaction.exceptions import TransactionFulfillmentCancelationException
from enterprise_subsidy.apps.transaction.utils import unenrollment_can_be_refunded

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -80,52 +80,6 @@ def listen_for_transaction_reversal(sender, **kwargs):
raise exc


def unenrollment_can_be_refunded(
content_metadata,
enterprise_course_enrollment,
):
"""
helper method to determine if an unenrollment is refundable
"""
# Retrieve the course start date from the content metadata
enrollment_course_run_key = enterprise_course_enrollment.get("course_id")
course_start_date = None
if content_metadata.get('content_type') == 'courserun':
course_start_date = content_metadata.get('start')
else:
for run in content_metadata.get('course_runs', []):
if run.get('key') == enrollment_course_run_key:
course_start_date = run.get('start')
break

if not course_start_date:
logger.warning(
f"No course start date found for course run: {enrollment_course_run_key}. "
"Unable to determine refundability."
)
return False

# https://2u-internal.atlassian.net/browse/ENT-6825
# OCM course refundability is defined as True IFF:
# ie MAX(enterprise enrollment created at, course start date) + 14 days > unenrolled_at date
enrollment_created_datetime = enterprise_course_enrollment.get("created")
enrollment_unenrolled_at_datetime = enterprise_course_enrollment.get("unenrolled_at")
course_start_datetime = datetime.fromisoformat(course_start_date)
refund_cutoff_date = max(course_start_datetime, enrollment_created_datetime) + timedelta(days=14)
if refund_cutoff_date > enrollment_unenrolled_at_datetime:
logger.info(
f"Course run: {enrollment_course_run_key} is refundable for enterprise customer user: "
f"{enterprise_course_enrollment.get('enterprise_customer_user')}. Writing Reversal record."
)
return True
else:
logger.info(
f"Unenrollment from course: {enrollment_course_run_key} by user: "
f"{enterprise_course_enrollment.get('enterprise_customer_user')} is not refundable."
)
return False


@receiver(LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED)
def handle_lc_enrollment_revoked(**kwargs):
"""
Expand All @@ -143,6 +97,12 @@ def handle_lc_enrollment_revoked(**kwargs):
learner_credit_course_enrollment (dict-like):
An openedx-events serialized representation of LearnerCreditEnterpriseCourseEnrollment.
"""
if not settings.ENABLE_HANDLE_LC_ENROLLMENT_REVOKED:
logger.info(
"Handling of LEARNER_CREDIT_COURSE_ENROLLMENT_REVOKED event has been disabled. "
"Skipping handle_lc_enrollment_revoked() handler."
)
return
revoked_enrollment_data = kwargs.get('learner_credit_course_enrollment')
fulfillment_uuid = revoked_enrollment_data.get("uuid")
enterprise_course_enrollment = revoked_enrollment_data.get("enterprise_course_enrollment")
Expand Down
64 changes: 2 additions & 62 deletions enterprise_subsidy/apps/transaction/tests/test_signal_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,10 @@
ReversalFactory,
TransactionFactory
)
from pytz import UTC

from enterprise_subsidy.apps.api_client.enterprise import EnterpriseApiClient
from enterprise_subsidy.apps.fulfillment.api import GEAGFulfillmentHandler
from enterprise_subsidy.apps.transaction.signals.handlers import (
handle_lc_enrollment_revoked,
unenrollment_can_be_refunded
)
from enterprise_subsidy.apps.transaction.signals.handlers import handle_lc_enrollment_revoked
from test_utils.utils import MockResponse


Expand Down Expand Up @@ -105,7 +101,6 @@ def test_transaction_reversed_signal_without_fulfillment_identifier(
assert mock_oauth_client.return_value.post.call_count == 0
self.assertFalse(mock_send_event_bus_reversed.called)


@ddt.data(
# Happy path.
{},
Expand Down Expand Up @@ -141,6 +136,7 @@ def test_transaction_reversed_signal_without_fulfillment_identifier(
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.reverse_transaction')
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.unenrollment_can_be_refunded')
@mock.patch('enterprise_subsidy.apps.transaction.signals.handlers.ContentMetadataApi.get_content_metadata')
@override_settings(ENABLE_HANDLE_LC_ENROLLMENT_REVOKED=True)
def test_handle_lc_enrollment_revoked(
self,
mock_get_content_metadata,
Expand Down Expand Up @@ -184,59 +180,3 @@ def test_handle_lc_enrollment_revoked(
assert any(re.search(expected_log_regex, log) for log in logs.output)
if expected_reverse_transaction_called:
mock_reverse_transaction.assert_called_once_with(transaction, unenroll_time=enrollment_unenrolled_at)

@ddt.data(
# ALMOST non-refundable due to enterprise_enrollment_created_at.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 10, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 1, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 23, tzinfo=UTC),
"expected_refundable": True,
},
# Non-refundable due to enterprise_enrollment_created_at.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 10, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 1, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 24, tzinfo=UTC),
"expected_refundable": False,
},
# ALMOST non-refundable due to course_start_date.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 1, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 10, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 23, tzinfo=UTC),
"expected_refundable": True,
},
# Non-refundable due to course_start_date.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 1, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 10, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 24, tzinfo=UTC),
"expected_refundable": False,
},
)
@ddt.unpack
def test_unenrollment_can_be_refunded(
self,
enterprise_enrollment_created_at,
course_start_date,
unenrolled_at,
expected_refundable,
):
"""
Make sure the following forumla is respected:
MAX(enterprise_enrollment_created_at, course_start_date) + 14 days > unenrolled_at
"""
test_content_metadata = {
"content_type": "courserun",
"start": course_start_date.strftime('%Y-%m-%dT%H:%M:%SZ'),
}
test_enterprise_course_enrollment = {
"created": enterprise_enrollment_created_at,
"unenrolled_at": unenrolled_at,
}
assert unenrollment_can_be_refunded(
test_content_metadata,
test_enterprise_course_enrollment,
) == expected_refundable
73 changes: 73 additions & 0 deletions enterprise_subsidy/apps/transaction/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""
Tests for Transaction utils.
"""
from datetime import datetime

import ddt
from django.test import TestCase
from pytz import UTC

from enterprise_subsidy.apps.transaction.signals.handlers import unenrollment_can_be_refunded


@ddt.ddt
class TransactionUtilsTestCase(TestCase):
"""
Tests for Transaction utils.
"""

@ddt.data(
# ALMOST non-refundable due to enterprise_enrollment_created_at.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 10, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 1, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 23, tzinfo=UTC),
"expected_refundable": True,
},
# Non-refundable due to enterprise_enrollment_created_at.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 10, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 1, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 24, tzinfo=UTC),
"expected_refundable": False,
},
# ALMOST non-refundable due to course_start_date.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 1, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 10, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 23, tzinfo=UTC),
"expected_refundable": True,
},
# Non-refundable due to course_start_date.
{
"enterprise_enrollment_created_at": datetime(2020, 1, 1, tzinfo=UTC),
"course_start_date": datetime(2020, 1, 10, tzinfo=UTC),
"unenrolled_at": datetime(2020, 1, 24, tzinfo=UTC),
"expected_refundable": False,
},
)
@ddt.unpack
def test_unenrollment_can_be_refunded(
self,
enterprise_enrollment_created_at,
course_start_date,
unenrolled_at,
expected_refundable,
):
"""
Make sure the following forumla is respected:
MAX(enterprise_enrollment_created_at, course_start_date) + 14 days > unenrolled_at
"""
test_content_metadata = {
"content_type": "courserun",
"start": course_start_date.strftime('%Y-%m-%dT%H:%M:%SZ'),
}
test_enterprise_course_enrollment = {
"created": enterprise_enrollment_created_at,
"unenrolled_at": unenrolled_at,
}
assert unenrollment_can_be_refunded(
test_content_metadata,
test_enterprise_course_enrollment,
) == expected_refundable
Loading

0 comments on commit 1589455

Please sign in to comment.