Skip to content

Commit 11b2271

Browse files
committed
feat: mgmt command to backfill renewals
ENT-11011
1 parent 4f3edc5 commit 11b2271

File tree

8 files changed

+485
-19
lines changed

8 files changed

+485
-19
lines changed

enterprise_access/apps/api/tests/test_tasks.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from uuid import uuid4
77

88
import ddt
9+
from django.test import override_settings
910

1011
from enterprise_access.apps.api.serializers import CouponCodeRequestSerializer, LicenseRequestSerializer
1112
from enterprise_access.apps.api.tasks import (
@@ -24,6 +25,7 @@
2425

2526

2627
@ddt.ddt
28+
@override_settings(SEGMENT_KEY='test_key')
2729
class TestTasks(APITestWithMocks):
2830
"""
2931
Test tasks.
@@ -194,6 +196,7 @@ def test_unlink_users_from_enterprise_task(self, mock_lms_client):
194196
)
195197

196198

199+
@override_settings(SEGMENT_KEY='test_key')
197200
class TestLicenseAssignmentTasks(APITestWithMocks):
198201
"""
199202
Test license assignment tasks.
@@ -272,6 +275,7 @@ def test_update_license_requests_after_assignments_task(self):
272275
)
273276

274277

278+
@override_settings(SEGMENT_KEY='test_key')
275279
class TestCouponCodeAssignmentTasks(APITestWithMocks):
276280
"""
277281
Test coupon code assignment tasks.

enterprise_access/apps/api/v1/tests/test_browse_and_request_views.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import ddt
1111
from django.conf import settings
12+
from django.test import override_settings
1213
from rest_framework import status
1314
from rest_framework.reverse import reverse
1415

@@ -84,18 +85,27 @@
8485

8586

8687
@ddt.ddt
88+
@override_settings(SEGMENT_KEY='test_key')
8789
class TestLicenseRequestViewSet(BaseEnterpriseAccessTestCase):
8890
"""
8991
Tests for LicenseRequestViewSet.
9092
"""
93+
@classmethod
94+
def setUpTestData(cls):
95+
# license request with no associations to the user
96+
cls.other_license_request = LicenseRequestFactory()
9197

9298
def setUp(self):
9399
super().setUp()
94100

95-
self.set_jwt_cookie([{
96-
'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE,
97-
'context': str(self.enterprise_customer_uuid_1),
98-
}])
101+
if not hasattr(self, '_original_cookies'):
102+
self.set_jwt_cookie(roles_and_contexts=[{
103+
'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE,
104+
'context': str(self.enterprise_customer_uuid_1),
105+
}])
106+
self._original_cookies = self.client.cookies
107+
else:
108+
self.client.cookies = self._original_cookies
99109

100110
# license requests for the user
101111
self.user_license_request_1 = LicenseRequestFactory(
@@ -112,9 +122,6 @@ def setUp(self):
112122
enterprise_customer_uuid=self.enterprise_customer_uuid_1
113123
)
114124

115-
# license request with no associations to the user
116-
self.other_license_request = LicenseRequestFactory()
117-
118125
def test_list_as_enterprise_learner(self):
119126
"""
120127
Test that an enterprise learner should see all their requests.
@@ -739,6 +746,7 @@ def test_overview_happy_path(self):
739746

740747

741748
@ddt.ddt
749+
@override_settings(SEGMENT_KEY='test_key')
742750
class TestCouponCodeRequestViewSet(BaseEnterpriseAccessTestCase):
743751
"""
744752
Tests for CouponCodeRequestViewSet.
@@ -1191,6 +1199,7 @@ def test_decline_unlink_users(self, mock_unlink_users_from_enterprise_task):
11911199

11921200

11931201
@ddt.ddt
1202+
@override_settings(SEGMENT_KEY='test_key')
11941203
class TestSubsidyRequestCustomerConfigurationViewSet(APITestWithMocks):
11951204
"""
11961205
Tests for SubsidyRequestCustomerConfigurationViewSet.
@@ -1538,6 +1547,7 @@ def test_partial_update_no_tasks(
15381547

15391548

15401549
@ddt.ddt
1550+
@override_settings(SEGMENT_KEY='test_key')
15411551
class TestLearnerCreditRequestViewSet(BaseEnterpriseAccessTestCase):
15421552
"""
15431553
Tests for LearnerCreditRequestViewSet.

enterprise_access/apps/api/v1/tests/utils.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@ class BaseEnterpriseAccessTestCase(APITestWithMocks):
1212
Tests for SubsidyRequestViewSet.
1313
"""
1414

15+
@classmethod
16+
def setUpClass(cls):
17+
super().setUpClass()
18+
cls.enterprise_customer_uuid_1 = uuid4()
19+
cls.enterprise_customer_uuid_2 = uuid4()
20+
1521
def setUp(self):
1622
super().setUp()
17-
self.set_jwt_cookie([
18-
{
19-
'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE,
20-
'context': ALL_ACCESS_CONTEXT
21-
}
22-
])
23-
24-
self.enterprise_customer_uuid_1 = uuid4()
25-
self.enterprise_customer_uuid_2 = uuid4()
23+
if not hasattr(self, '_operator_cookies'):
24+
self.set_jwt_cookie([
25+
{
26+
'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE,
27+
'context': ALL_ACCESS_CONTEXT
28+
}
29+
])
30+
self._operator_cookies = self.client.cookies
31+
else:
32+
self.client.cookies = self._operator_cookies
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""
2+
Management command to backfill SelfServiceSubscriptionRenewal records from existing provisioning workflows.
3+
"""
4+
from django.core.management.base import BaseCommand
5+
from django.db import transaction
6+
7+
from enterprise_access.apps.customer_billing.models import (
8+
CheckoutIntent,
9+
SelfServiceSubscriptionRenewal,
10+
StripeEventSummary
11+
)
12+
from enterprise_access.apps.provisioning.models import (
13+
GetCreateSubscriptionPlanRenewalStep,
14+
ProvisionNewCustomerWorkflow
15+
)
16+
17+
18+
class Command(BaseCommand):
19+
"""
20+
Command to backfill SelfServiceSubscriptionRenewal records from existing provisioning workflows.
21+
22+
This command finds completed provisioning workflows that have subscription plan renewals
23+
but no corresponding SelfServiceSubscriptionRenewal tracking records, and creates them.
24+
"""
25+
help = 'Backfill SelfServiceSubscriptionRenewal records from existing provisioning workflows'
26+
27+
def add_arguments(self, parser):
28+
parser.add_argument(
29+
'--batch-size',
30+
type=int,
31+
default=100,
32+
help='Number of records to process in each batch (default: 100)',
33+
)
34+
parser.add_argument(
35+
'--dry-run',
36+
action='store_true',
37+
help='Show what would be processed without actually creating records',
38+
)
39+
40+
def _write(self, msg):
41+
self.stdout.write(msg)
42+
43+
def _write_warning(self, msg):
44+
self._write(self.style.WARNING(msg))
45+
46+
def _write_error(self, msg):
47+
self.stderr.write(self.style.ERROR(msg))
48+
49+
def _write_success(self, msg):
50+
self._write(self.style.SUCCESS(msg))
51+
52+
def handle(self, *args, **options):
53+
"""
54+
Execute the backfill command.
55+
"""
56+
batch_size = options['batch_size']
57+
dry_run = options['dry_run']
58+
59+
if dry_run:
60+
self._write_warning('DRY RUN MODE - No records will be created')
61+
62+
# Start with successful GetCreateSubscriptionPlanRenewalSteps
63+
steps_queryset = GetCreateSubscriptionPlanRenewalStep.objects.filter(
64+
succeeded_at__isnull=False,
65+
output_data__isnull=False,
66+
workflow_record_uuid__isnull=False,
67+
)
68+
69+
total_steps = steps_queryset.count()
70+
self._write(f'Found {total_steps} successful subscription renewal steps')
71+
72+
created_count = 0
73+
skipped_count = 0
74+
error_count = 0
75+
76+
for i in range(0, total_steps, batch_size):
77+
batch_steps = steps_queryset[i:i + batch_size]
78+
79+
for step in batch_steps:
80+
try:
81+
result = self._handle_renewal_step(step, dry_run)
82+
if result == 'created':
83+
created_count += 1
84+
elif result == 'skipped':
85+
skipped_count += 1
86+
except Exception as exc: # pylint: disable=broad-exception-caught
87+
error_count += 1
88+
self._write_error(f'Error processing renewal step {step.uuid}: {exc}')
89+
90+
# Progress update
91+
processed = min(i + batch_size, total_steps)
92+
self._write(f'Processed {processed}/{total_steps} renewal steps...')
93+
94+
self._write_success(
95+
f'\nBackfill complete! Created: {created_count}, '
96+
f'Skipped: {skipped_count}, Errors: {error_count}'
97+
)
98+
99+
def _handle_renewal_step(self, step: GetCreateSubscriptionPlanRenewalStep, dry_run: bool) -> str:
100+
"""
101+
Process a single renewal step to create missing SelfServiceSubscriptionRenewal records.
102+
103+
Returns:
104+
str: 'created', 'skipped', or raises exception
105+
"""
106+
# Find the related workflow for this step
107+
try:
108+
workflow = ProvisionNewCustomerWorkflow.objects.get(uuid=step.workflow_record_uuid)
109+
except ProvisionNewCustomerWorkflow.DoesNotExist:
110+
return 'skipped'
111+
112+
# Check if there's a checkout intent on the related workflow
113+
try:
114+
checkout_intent = workflow.checkoutintent
115+
except CheckoutIntent.DoesNotExist:
116+
return 'skipped'
117+
118+
renewal_id = step.output_object.id
119+
120+
# Check if SelfServiceSubscriptionRenewal already exists for this checkout intent
121+
existing_renewal = SelfServiceSubscriptionRenewal.objects.filter(
122+
checkout_intent=checkout_intent,
123+
subscription_plan_renewal_id=renewal_id
124+
).first()
125+
126+
if existing_renewal:
127+
return 'skipped'
128+
129+
if dry_run:
130+
self._write(
131+
f'Would create SelfServiceSubscriptionRenewal for '
132+
f'checkout_intent {checkout_intent.id}, renewal {renewal_id}'
133+
)
134+
return 'created'
135+
136+
# Get the latest summary and then create a SelfServiceSubscriptionRenewal record
137+
latest_summary = StripeEventSummary.get_latest_for_checkout_intent(
138+
checkout_intent,
139+
stripe_subscription_id__isnull=False,
140+
stripe_event_data__isnull=False
141+
)
142+
if not latest_summary:
143+
raise Exception(f'No summary for checkout intent {checkout_intent}')
144+
145+
with transaction.atomic():
146+
SelfServiceSubscriptionRenewal.objects.create(
147+
checkout_intent=checkout_intent,
148+
subscription_plan_renewal_id=renewal_id,
149+
stripe_event_data=latest_summary.stripe_event_data,
150+
stripe_subscription_id=latest_summary.stripe_subscription_id,
151+
)
152+
153+
self._write(
154+
f'Created SelfServiceSubscriptionRenewal for '
155+
f'checkout_intent {checkout_intent.id}, renewal {renewal_id}'
156+
)
157+
return 'created'

0 commit comments

Comments
 (0)