Skip to content

Commit 387a21b

Browse files
committed
feat: add provisioning OLI endpoint
also supports requests by checkout intent or uuid. response serializer returns both values ENT-11122
1 parent 17ed11b commit 387a21b

File tree

10 files changed

+397
-6
lines changed

10 files changed

+397
-6
lines changed

enterprise_access/apps/api/serializers/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@
2525
CustomerBillingCreateCheckoutSessionValidationFailedResponseSerializer,
2626
StripeEventSummaryReadOnlySerializer
2727
)
28-
from .provisioning import ProvisioningRequestSerializer, ProvisioningResponseSerializer
28+
from .provisioning import (
29+
ProvisioningRequestSerializer,
30+
ProvisioningResponseSerializer,
31+
SubscriptionPlanOLIUpdateResponseSerializer,
32+
SubscriptionPlanOLIUpdateSerializer
33+
)
2934
from .subsidy_access_policy import (
3035
GroupMemberWithAggregatesRequestSerializer,
3136
GroupMemberWithAggregatesResponseSerializer,

enterprise_access/apps/api/serializers/provisioning.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,62 @@ class ProvisioningResponseSerializer(BaseSerializer):
210210
enterprise_catalog = EnterpriseCatalogResponseSerializer()
211211
customer_agreement = CustomerAgreementResponseSerializer()
212212
subscription_plan = SubscriptionPlanResponseSerializer()
213+
214+
215+
class SubscriptionPlanOLIUpdateSerializer(BaseSerializer):
216+
"""
217+
Request serializer for updating a SubscriptionPlan's Salesforce OLI.
218+
"""
219+
checkout_intent_id = serializers.IntegerField(
220+
help_text='The integer ID of the CheckoutIntent associated with this subscription.',
221+
required=False,
222+
allow_null=True,
223+
)
224+
checkout_intent_uuid = serializers.UUIDField(
225+
help_text='The UUID of the CheckoutIntent associated with this subscription.',
226+
required=False,
227+
allow_null=True,
228+
)
229+
salesforce_opportunity_line_item = serializers.CharField(
230+
help_text='The Salesforce Opportunity Line Item ID to associate with the subscription plan.'
231+
)
232+
is_trial = serializers.BooleanField(
233+
required=False,
234+
default=False,
235+
help_text='Whether this OLI is for the trial plan (True) or paid plan (False).'
236+
)
237+
238+
def validate(self, data):
239+
"""
240+
Validates that exactly one of CheckoutIntent ``id`` and ``uuid`` is provided.
241+
"""
242+
if not (data.get('checkout_intent_id') or data.get('checkout_intent_uuid')):
243+
raise serializers.ValidationError('One of CheckoutIntent id or uuid is required')
244+
if data.get('checkout_intent_id') and data.get('checkout_intent_uuid'):
245+
raise serializers.ValidationError('Only one of CheckoutIntent id or uuid can be provided')
246+
return data
247+
248+
249+
class SubscriptionPlanOLIUpdateResponseSerializer(BaseSerializer):
250+
"""
251+
Response serializer for SubscriptionPlan OLI update.
252+
"""
253+
success = serializers.BooleanField(
254+
help_text='Whether the update was successful',
255+
)
256+
subscription_plan_uuid = serializers.UUIDField(
257+
help_text='The UUID identifier of the future plan (which receives the updated OLI value)',
258+
)
259+
salesforce_opportunity_line_item = serializers.CharField(
260+
help_text='The Salesforce Opportunity Line Item ID to associate with the subscription plan.'
261+
)
262+
checkout_intent_uuid = serializers.UUIDField(
263+
help_text='The UUID of the CheckoutIntent associated with this subscription.',
264+
required=False,
265+
allow_null=True,
266+
)
267+
checkout_intent_id = serializers.IntegerField(
268+
help_text='The integer ID of the CheckoutIntent associated with this subscription.',
269+
required=False,
270+
allow_null=True,
271+
)

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

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,3 +923,177 @@ def test_checkout_intent_different_slug_ignored(self, mock_lms_api_client, mock_
923923
# Verify workflow was still created successfully
924924
workflow = ProvisionNewCustomerWorkflow.objects.first()
925925
self.assertIsNotNone(workflow)
926+
927+
928+
@ddt.ddt
929+
class TestSubscriptionPlanOLIUpdateView(APITest):
930+
"""
931+
Tests for the SubscriptionPlan OLI update endpoint.
932+
"""
933+
934+
def setUp(self):
935+
super().setUp()
936+
self.user = UserFactory()
937+
self.checkout_intent = CheckoutIntent.objects.create(
938+
user=self.user,
939+
enterprise_slug='test-enterprise',
940+
enterprise_name='Test Enterprise',
941+
quantity=10,
942+
state=CheckoutIntentState.FULFILLED,
943+
expires_at=timezone.now() + timedelta(hours=1),
944+
enterprise_uuid=TEST_ENTERPRISE_UUID,
945+
)
946+
self.workflow = ProvisionNewCustomerWorkflow.objects.create(
947+
input_data={'test': 'data'}
948+
)
949+
self.checkout_intent.workflow = self.workflow
950+
self.checkout_intent.save()
951+
952+
self.subscription_plan_uuid = uuid.uuid4()
953+
# Create a subscription plan step with complete output data
954+
GetCreateSubscriptionPlanStep.objects.create(
955+
workflow_record_uuid=self.workflow.uuid,
956+
input_data={
957+
'title': 'Paid Plan',
958+
'is_trial': False,
959+
'salesforce_opportunity_line_item': 'existing_oli_123',
960+
'start_date': '2025-01-15T00:00:00Z',
961+
'expiration_date': '2025-12-31T23:59:59Z',
962+
'desired_num_licenses': 10,
963+
'product_id': 1,
964+
},
965+
output_data={
966+
'uuid': str(self.subscription_plan_uuid),
967+
'title': 'Paid Plan',
968+
'salesforce_opportunity_line_item': 'existing_oli_123',
969+
'created': '2025-01-01T00:00:00Z',
970+
'start_date': '2025-01-15T00:00:00Z',
971+
'expiration_date': '2025-12-31T23:59:59Z',
972+
'is_active': True,
973+
'is_current': True,
974+
'plan_type': 'Standard',
975+
'enterprise_catalog_uuid': str(TEST_CATALOG_UUID),
976+
'product': 1,
977+
}
978+
)
979+
980+
self.endpoint_url = reverse('api:v1:subscription-plan-oli-update')
981+
self.set_jwt_cookie([
982+
{
983+
'system_wide_role': SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE,
984+
'context': ALL_ACCESS_CONTEXT,
985+
},
986+
])
987+
988+
def tearDown(self):
989+
super().tearDown()
990+
CheckoutIntent.objects.all().delete()
991+
ProvisionNewCustomerWorkflow.objects.all().delete()
992+
GetCreateSubscriptionPlanStep.objects.all().delete()
993+
994+
@mock.patch('enterprise_access.apps.api.v1.views.provisioning.LicenseManagerApiClient')
995+
def test_successful_oli_update(self, mock_license_manager_client):
996+
"""Test successful update of SubscriptionPlan OLI."""
997+
mock_client = mock_license_manager_client.return_value
998+
mock_client.update_subscription_plan.return_value = {
999+
'uuid': str(self.subscription_plan_uuid),
1000+
'salesforce_opportunity_line_item': 'new_oli_456',
1001+
}
1002+
1003+
request_data = {
1004+
'checkout_intent_id': self.checkout_intent.id,
1005+
'salesforce_opportunity_line_item': 'new_oli_456',
1006+
'is_trial': False,
1007+
}
1008+
1009+
response = self.client.post(self.endpoint_url, data=request_data)
1010+
1011+
self.assertEqual(response.status_code, status.HTTP_200_OK, response.json())
1012+
response_data = response.json()
1013+
self.assertTrue(response_data['success'])
1014+
self.assertEqual(str(response_data['subscription_plan_uuid']), str(self.subscription_plan_uuid))
1015+
self.assertEqual(response_data['salesforce_opportunity_line_item'], 'new_oli_456')
1016+
self.assertEqual(str(response_data['checkout_intent_uuid']), str(self.checkout_intent.uuid))
1017+
self.assertEqual(str(response_data['checkout_intent_id']), str(self.checkout_intent.id))
1018+
1019+
mock_client.update_subscription_plan.assert_called_once_with(
1020+
subscription_uuid=str(self.subscription_plan_uuid),
1021+
salesforce_opportunity_line_item='new_oli_456'
1022+
)
1023+
1024+
def test_checkout_intent_not_found(self):
1025+
"""Test error when CheckoutIntent doesn't exist."""
1026+
request_data = {
1027+
'checkout_intent_uuid': str(uuid.uuid4()), # Non-existent id
1028+
'salesforce_opportunity_line_item': 'new_oli_456',
1029+
}
1030+
1031+
response = self.client.post(self.endpoint_url, data=request_data)
1032+
1033+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
1034+
response_data = response.json()
1035+
if isinstance(response_data, dict):
1036+
self.assertIn('not found', response_data.get('detail', '').lower())
1037+
else:
1038+
self.assertTrue(any('not found' in str(item).lower() for item in response_data))
1039+
1040+
def test_no_workflow_associated(self):
1041+
"""Test error when CheckoutIntent has no workflow."""
1042+
checkout_intent_no_workflow = CheckoutIntent.objects.create(
1043+
user=UserFactory(),
1044+
enterprise_slug='test-no-workflow',
1045+
enterprise_name='Test No Workflow',
1046+
quantity=5,
1047+
state=CheckoutIntentState.PAID,
1048+
expires_at=timezone.now() + timedelta(hours=1),
1049+
)
1050+
1051+
request_data = {
1052+
'checkout_intent_uuid': str(checkout_intent_no_workflow.uuid),
1053+
'salesforce_opportunity_line_item': 'new_oli_456',
1054+
}
1055+
1056+
response = self.client.post(self.endpoint_url, data=request_data)
1057+
1058+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
1059+
response_data = response.json()
1060+
# Handle both dict and list response formats
1061+
if isinstance(response_data, dict):
1062+
self.assertIn('no associated workflow', response_data.get('detail', '').lower())
1063+
else:
1064+
# If it's a list (field errors), check the first item
1065+
self.assertTrue(any('no associated workflow' in str(item).lower() for item in response_data))
1066+
1067+
@mock.patch('enterprise_access.apps.api.v1.views.provisioning.LicenseManagerApiClient')
1068+
def test_license_manager_api_error(self, mock_license_manager_client):
1069+
"""Test error handling when License Manager API fails."""
1070+
mock_client = mock_license_manager_client.return_value
1071+
mock_client.update_subscription_plan.side_effect = Exception('License Manager API Error')
1072+
1073+
request_data = {
1074+
'checkout_intent_uuid': str(self.checkout_intent.uuid),
1075+
'salesforce_opportunity_line_item': 'new_oli_456',
1076+
'is_trial': False,
1077+
}
1078+
1079+
response = self.client.post(self.endpoint_url, data=request_data)
1080+
1081+
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
1082+
response_data = response.json()
1083+
if isinstance(response_data, dict):
1084+
self.assertIn('failed to update subscription plan', response_data.get('detail', '').lower())
1085+
else:
1086+
self.assertTrue(any('failed to update subscription plan' in str(item).lower() for item in response_data))
1087+
1088+
def test_unauthorized_access(self):
1089+
"""Test that unauthorized users cannot access the endpoint."""
1090+
self.set_jwt_cookie([]) # Remove authorization
1091+
1092+
request_data = {
1093+
'checkout_intent_id': str(self.checkout_intent.id),
1094+
'salesforce_opportunity_line_item': 'new_oli_456',
1095+
}
1096+
1097+
response = self.client.post(self.endpoint_url, data=request_data)
1098+
1099+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

enterprise_access/apps/api/v1/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@
6262
views.ProvisioningCreateView.as_view(),
6363
name='provisioning-create',
6464
),
65+
path(
66+
'provisioning/subscription-plan-oli-update',
67+
views.SubscriptionPlanOLIUpdateView.as_view(),
68+
name='subscription-plan-oli-update',
69+
),
6570
]
6671

6772
urlpatterns += router.urls

enterprise_access/apps/api/v1/views/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from .content_assignments.assignments import LearnerContentAssignmentViewSet
1818
from .content_assignments.assignments_admin import LearnerContentAssignmentAdminViewSet
1919
from .customer_billing import CheckoutIntentViewSet, CustomerBillingViewSet, StripeEventSummaryViewSet
20-
from .provisioning import ProvisioningCreateView
20+
from .provisioning import ProvisioningCreateView, SubscriptionPlanOLIUpdateView
2121
from .subsidy_access_policy import (
2222
SubsidyAccessPolicyAllocateViewset,
2323
SubsidyAccessPolicyGroupViewset,

0 commit comments

Comments
 (0)