-
Notifications
You must be signed in to change notification settings - Fork 18
feat: checkout context BFF skeleton #778
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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| """ | ||
| Serializers for the Checkout BFF endpoints. | ||
| """ | ||
| from rest_framework import serializers | ||
|
|
||
|
|
||
| # pylint: disable=abstract-method | ||
| class EnterpriseCustomerSerializer(serializers.Serializer): | ||
| """ | ||
| Serializer for enterprise customer data in checkout context. | ||
| """ | ||
| customer_uuid = serializers.CharField() | ||
| customer_name = serializers.CharField() | ||
| customer_slug = serializers.CharField() | ||
| stripe_customer_id = serializers.CharField() | ||
| is_self_service = serializers.BooleanField(default=False) | ||
| admin_portal_url = serializers.CharField() | ||
|
|
||
|
|
||
| class PriceSerializer(serializers.Serializer): | ||
| """ | ||
| Serializer for Stripe price objects in checkout context. | ||
| """ | ||
| id = serializers.CharField(help_text="Stripe Price ID") | ||
| product = serializers.CharField(help_text="Stripe Product ID") | ||
| lookup_key = serializers.CharField(help_text="Lookup key for this price") | ||
| recurring = serializers.DictField( | ||
| help_text="Recurring billing configuration" | ||
| ) | ||
| currency = serializers.CharField(help_text="Currency code (e.g. 'usd')") | ||
| unit_amount = serializers.IntegerField(help_text="Price amount in cents") | ||
| unit_amount_decimal = serializers.CharField(help_text="Price amount as decimal string") | ||
|
|
||
|
|
||
| class PricingDataSerializer(serializers.Serializer): | ||
| """ | ||
| Serializer for pricing data in checkout context. | ||
| """ | ||
| default_by_lookup_key = serializers.CharField( | ||
| help_text="Lookup key for the default price option" | ||
| ) | ||
| prices = PriceSerializer(many=True, help_text="Available price options") | ||
|
|
||
|
|
||
| class QuantityConstraintSerializer(serializers.Serializer): | ||
| """ | ||
| Serializer for quantity constraints. | ||
| """ | ||
| min = serializers.IntegerField(help_text="Minimum allowed quantity") | ||
| max = serializers.IntegerField(help_text="Maximum allowed quantity") | ||
|
|
||
|
|
||
| class SlugConstraintSerializer(serializers.Serializer): | ||
| """ | ||
| Serializer for enterprise slug constraints. | ||
| """ | ||
| min_length = serializers.IntegerField(help_text="Minimum slug length") | ||
| max_length = serializers.IntegerField(help_text="Maximum slug length") | ||
| pattern = serializers.CharField(help_text="Regex pattern for valid slugs") | ||
|
|
||
|
|
||
| class FieldConstraintsSerializer(serializers.Serializer): | ||
| """ | ||
| Serializer for field constraints in checkout context. | ||
|
|
||
| TODO: the field constraints should be expanded to more closely match the mins/maxes within this code block: | ||
| https://github.com/edx/frontend-app-enterprise-checkout/blob/main/src/constants.ts#L13-L39 | ||
| """ | ||
| quantity = QuantityConstraintSerializer(help_text="Constraints for license quantity") | ||
| enterprise_slug = SlugConstraintSerializer(help_text="Constraints for enterprise slug") | ||
|
|
||
|
|
||
| class CheckoutContextResponseSerializer(serializers.Serializer): | ||
| """ | ||
| Serializer for the checkout context response. | ||
| """ | ||
| existing_customers_for_authenticated_user = EnterpriseCustomerSerializer( | ||
| many=True, | ||
| help_text="Enterprise customers associated with the authenticated user (empty for unauthenticated users)" | ||
| ) | ||
| pricing = PricingDataSerializer(help_text="Available pricing options") | ||
| field_constraints = FieldConstraintsSerializer(help_text="Constraints for form fields") |
123 changes: 123 additions & 0 deletions
123
enterprise_access/apps/api/v1/tests/test_checkout_bff_views.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| """ | ||
| Tests for the Checkout BFF ViewSet. | ||
| """ | ||
| import uuid | ||
|
|
||
| from django.urls import reverse | ||
| from rest_framework import status | ||
|
|
||
| from enterprise_access.apps.api.serializers.checkout_bff import ( | ||
| CheckoutContextResponseSerializer, | ||
| EnterpriseCustomerSerializer, | ||
| PriceSerializer | ||
| ) | ||
| from enterprise_access.apps.core.constants import SYSTEM_ENTERPRISE_LEARNER_ROLE | ||
| from test_utils import APITest | ||
|
|
||
|
|
||
| class CheckoutBFFViewSetTests(APITest): | ||
| """ | ||
| Tests for the Checkout BFF ViewSet. | ||
| """ | ||
|
|
||
| def setUp(self): | ||
| super().setUp() | ||
| self.url = reverse('api:v1:checkout-bff-context') | ||
|
|
||
| def test_context_endpoint_unauthenticated_access(self): | ||
| """ | ||
| Test that unauthenticated users can access the context endpoint. | ||
| """ | ||
| response = self.client.post(self.url, {}, format='json') | ||
| self.assertEqual(response.status_code, status.HTTP_200_OK) | ||
|
|
||
| # Verify response structure matches our expectations | ||
| self.assertIn('existing_customers_for_authenticated_user', response.data) | ||
| self.assertIn('pricing', response.data) | ||
| self.assertIn('field_constraints', response.data) | ||
|
|
||
| # For unauthenticated users, existing_customers should be empty | ||
| self.assertEqual(len(response.data['existing_customers_for_authenticated_user']), 0) | ||
| # TODO: remove | ||
| self.assertIsNone(response.data['user_id']) | ||
|
|
||
| def test_context_endpoint_authenticated_access(self): | ||
| """ | ||
| Test that authenticated users can access the context endpoint. | ||
| """ | ||
| self.set_jwt_cookie([{ | ||
| 'system_wide_role': SYSTEM_ENTERPRISE_LEARNER_ROLE, | ||
| 'context': str(uuid.uuid4()), | ||
| }]) | ||
|
|
||
| response = self.client.post(self.url, {}, format='json') | ||
| self.assertEqual(response.status_code, status.HTTP_200_OK) | ||
|
|
||
| # Verify response structure matches our expectations | ||
| self.assertIn('existing_customers_for_authenticated_user', response.data) | ||
| self.assertIn('pricing', response.data) | ||
| self.assertIn('field_constraints', response.data) | ||
| # TODO: remove | ||
| self.assertEqual(response.data['user_id'], self.user.id) | ||
|
|
||
| def test_response_serializer_validation(self): | ||
| """ | ||
| Test that our response serializer validates the expected response structure. | ||
| """ | ||
| # Create sample data matching our expected response structure | ||
| sample_data = { | ||
| 'existing_customers_for_authenticated_user': [], | ||
| 'pricing': { | ||
| 'default_by_lookup_key': 'b2b_enterprise_self_service_yearly', | ||
| 'prices': [] | ||
| }, | ||
| 'field_constraints': { | ||
| 'quantity': {'min': 5, 'max': 30}, | ||
| 'enterprise_slug': { | ||
| 'min_length': 3, | ||
| 'max_length': 30, | ||
| 'pattern': '^[a-z0-9-]+$' | ||
| } | ||
| } | ||
| } | ||
|
|
||
| # Validate using our serializer | ||
| serializer = CheckoutContextResponseSerializer(data=sample_data) | ||
| self.assertTrue(serializer.is_valid(), serializer.errors) | ||
|
|
||
| def test_enterprise_customer_serializer(self): | ||
| """ | ||
| Test that EnterpriseCustomerSerializer correctly validates data. | ||
| """ | ||
| sample_data = { | ||
| 'customer_uuid': 'abc123', | ||
| 'customer_name': 'Test Enterprise', | ||
| 'customer_slug': 'test-enterprise', | ||
| 'stripe_customer_id': 'cus_123ABC', | ||
| 'is_self_service': True, | ||
| 'admin_portal_url': 'https://example.com/enterprise/test-enterprise' | ||
| } | ||
|
|
||
| serializer = EnterpriseCustomerSerializer(data=sample_data) | ||
| self.assertTrue(serializer.is_valid(), serializer.errors) | ||
|
|
||
| def test_price_serializer(self): | ||
| """ | ||
| Test that PriceSerializer correctly validates data. | ||
| """ | ||
| sample_data = { | ||
| 'id': 'price_123ABC', | ||
| 'product': 'prod_123ABC', | ||
| 'lookup_key': 'b2b_enterprise_self_service_yearly', | ||
| 'recurring': { | ||
| 'interval': 'month', | ||
| 'interval_count': 12, | ||
| 'trial_period_days': 14, | ||
| }, | ||
| 'currency': 'usd', | ||
| 'unit_amount': 10000, | ||
| 'unit_amount_decimal': '10000' | ||
| } | ||
|
|
||
| serializer = PriceSerializer(data=sample_data) | ||
| self.assertTrue(serializer.is_valid(), serializer.errors) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """ | ||
| ViewSet for the Checkout BFF endpoints. | ||
| """ | ||
| import logging | ||
|
|
||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | ||
| from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication | ||
| from rest_framework.decorators import action | ||
| from rest_framework.permissions import OR, AllowAny, IsAuthenticated | ||
| from rest_framework.response import Response | ||
|
|
||
| from enterprise_access.apps.api.serializers.checkout_bff import CheckoutContextResponseSerializer | ||
| from enterprise_access.apps.api.v1.views.bffs.common import BaseUnauthenticatedBFFViewSet | ||
| from enterprise_access.apps.bffs.context import BaseHandlerContext | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class CheckoutBFFViewSet(BaseUnauthenticatedBFFViewSet): | ||
| """ | ||
| ViewSet for checkout-related BFF endpoints. | ||
|
|
||
| These endpoints serve both authenticated and unauthenticated users. | ||
| """ | ||
|
|
||
| authentication_classes = [JwtAuthentication] | ||
|
|
||
| def get_permissions(self): | ||
| """ | ||
| Compose authenticated and unauthenticated permissions | ||
| to allow access to both user types, so that we can access ``request.user`` | ||
| and get either a hydrated user object from the JWT, or an AnonymousUser | ||
| if not authenticated. | ||
| """ | ||
| return [OR(IsAuthenticated(), AllowAny())] | ||
|
|
||
| @extend_schema( | ||
| operation_id="checkout_context", | ||
| summary="Get checkout context", | ||
| description=( | ||
| "Provides context information for the checkout flow, including pricing options " | ||
| "and, for authenticated users, associated enterprise customers." | ||
| ), | ||
| responses={ | ||
| 200: OpenApiResponse( | ||
| description="Success response with checkout context data.", | ||
| response=CheckoutContextResponseSerializer, | ||
| ), | ||
| }, | ||
| tags=["Checkout BFF"], | ||
| ) | ||
| @action(detail=False, methods=["post"], url_path="context") | ||
| def context(self, request): | ||
| """ | ||
| Provides context information for the checkout flow. | ||
|
|
||
| This includes pricing options for self-service subscription plans and, | ||
| for authenticated users, information about associated enterprise customers. | ||
| """ | ||
| # We'll eventually replace this with proper handler loading | ||
| # For now, let's return a skeleton response | ||
| context_data = { | ||
| # TODO: user_id is just here for testing purposes related to self.get_permissions(), | ||
| # remove once this view actually utilizes request.user | ||
| "user_id": request.user.id if request.user.is_authenticated else None, | ||
| "existing_customers_for_authenticated_user": [], | ||
| "pricing": { | ||
| "default_by_lookup_key": "b2b_enterprise_self_service_yearly", | ||
| "prices": [] | ||
| }, | ||
| "field_constraints": { | ||
| "quantity": {"min": 5, "max": 30}, | ||
| "enterprise_slug": {"min_length": 3, "max_length": 30, "pattern": "^[a-z0-9-]+$"} | ||
| } | ||
| } | ||
|
|
||
| # Eventually we'll use the BFF pattern to load and process data | ||
| # context_data, status_code = self.load_route_data_and_build_response( | ||
| # request, | ||
| # CheckoutContextHandler, | ||
| # CheckoutContextResponseBuilder | ||
| # ) | ||
|
|
||
| return Response(context_data) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,3 +79,4 @@ | |
| PRODUCT_ID_TO_CATALOG_QUERY_ID_MAPPING = { | ||
| '1': 42, | ||
| } | ||
| ENABLE_CUSTOMER_BILLING_API = True | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Note] I wonder if
request.userworks with this BaseUnauthenticatedBFFViewSet? I think the answer is yes, but just wanted to note that subsequent PRs to actually implement this skeleton will depend onrequest.usergetting set to the logged-in User in order to successfully extractlms_user_idto help calculateexisting_customers_for_authenticated_user.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Django gives us anonymous users out of the box, here's a breakpoint on an un-auth'd request:
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gotcha, I wonder if setting viewset auth vars like this would help:
authentication_classes = [JwtAuthentication]permission_classes = [IsAuthenticated, AllowAny]The idea is that unauthenticated requests would fail
JwtAuthenticationand getrequest.user = AnonymousUserbut passAllowAnypermmission check, while authenticated requests would go through bothJwtAuthentication->IsAuthenticated.Testing...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh you only tested un-authed, so it might still work out of the box for authenticated users...I think we can address this in a separate PR.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After local testing, it doesn't seem to work out of the box, but like I said I think we can address in a separate PR more specific to the BFF framework code.
I suspect that leveraging permission composability from DRF 3.9+ (we run 3.16 today) would make the solution quite simple with the
|operator:But this currently breaks internal logic within edx_rest_framework_extensions:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Relevant PR: openedx/edx-drf-extensions#528