11468 pr f- Academy products API Endpoint#179
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a public, unauthenticated academy-products list/retrieve API (with optional sync from enterprise-catalog), introduces academy product serializers, and refactors CheckoutIntent to support multiple active intents per user via a stripe_product_id and partial unique constraints. Also renames EnterpriseAcademy.enterprise_catalog_uuid to catalog_query_id and updates BFF tests to mock CheckoutIntent.for_user.
Changes:
- New
AcademyProductsViewSet(public,ScopedRateThrottle) with optionalsync=truethat pulls academies/catalogs from enterprise-catalog and upsertsEnterpriseAcademyrows. CheckoutIntent.userchanged fromOneToOneField→ForeignKey, addedstripe_product_id, two partial unique constraints scoped to active states, and a richerfor_user(...)lookup.- Migration renaming
enterprise_catalog_uuid→catalog_query_id, new academy serializers, settings keys (throttle rate, S3 thumbnail base, sync allowlists/overrides), and reusableEnterpriseCatalogApiV1Client.get_academies/get_catalogs(not actually used by the sync path).
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| enterprise_access/settings/base.py | Adds throttle rate, S3 thumbnail base URL, and academy sync configuration settings. |
| enterprise_access/apps/api/v1/views/customer_billing.py | Adds public AcademyProductsViewSet with list/retrieve, filtering, sync, and Stripe price fallback. |
| enterprise_access/apps/api/v1/views/init.py | Exports AcademyProductsViewSet. |
| enterprise_access/apps/api/v1/urls.py | Registers academy-products route under v1 router. |
| enterprise_access/apps/api/serializers/customer_billing.py | Adds academy product/price/list response serializers. |
| enterprise_access/apps/api/serializers/init.py | Exports new academy serializers. |
| enterprise_access/apps/api/v1/tests/test_customer_billing.py | Adds API and helper-level tests for academy endpoint behavior. |
| enterprise_access/apps/customer_billing/models.py | Renames academy catalog field, switches CheckoutIntent.user to FK, adds stripe_product_id + constraints, rewrites for_user. |
| enterprise_access/apps/customer_billing/migrations/0032_…py | Migration for renames, new field, FK change, and partial unique constraints. |
| enterprise_access/apps/customer_billing/tests/test_models.py | Tests for renamed field, new constraints, and for_user semantics. |
| enterprise_access/apps/api_client/enterprise_catalog_client.py | Adds get_academies/get_catalogs to v1 client (currently unused by sync). |
| enterprise_access/apps/api_client/tests/test_enterprise_catalog_client.py | Tests new client methods. |
| enterprise_access/apps/bffs/tests/test_checkout_handlers.py | Updates mocks to target CheckoutIntent.for_user. |
| enterprise_access/apps/api/v1/tests/test_checkout_bff_views.py | Updates BFF test mocks to use CheckoutIntent.for_user. |
| def _maybe_sync(self): | ||
| if self.request.query_params.get('sync') != 'true': | ||
| return | ||
| self._sync_essential_academies() |
| except Exception as exc: # pylint: disable=broad-except | ||
| logger.exception('Unable to fetch academy products: %s', exc) | ||
| return Response( | ||
| {'error_code': 'academy_service_unavailable', 'detail': 'Retry later.'}, | ||
| status=status.HTTP_502_BAD_GATEWAY, | ||
| ) |
| for attr_name in ('catalog_query_id', 'catalog_query_int_id'): | ||
| value = getattr(academy, attr_name, None) | ||
| if isinstance(value, bool): | ||
| continue | ||
| if isinstance(value, int): | ||
| return value | ||
| if isinstance(value, str) and value.strip().isdigit(): | ||
| return int(value.strip()) |
| def _sync_essential_academies(self): | ||
| enterprise_customer_uuid = (getattr(settings, 'ACADEMY_SYNC_ENTERPRISE_CUSTOMER_UUID', '') or '').strip() | ||
| client = EnterpriseCatalogApiV1Client() | ||
|
|
||
| academy_params = {'enterprise_customer': enterprise_customer_uuid} if enterprise_customer_uuid else None | ||
| academies_response = client.client.get(urljoin(client.api_base_url, 'academies/'), params=academy_params) | ||
| academies_response.raise_for_status() | ||
| academy_items = self._extract_results(academies_response.json()) | ||
|
|
||
| allowlist_uuids = { | ||
| str(value).strip() | ||
| for value in (getattr(settings, 'ESSENTIAL_ACADEMY_UUID_ALLOWLIST', []) or []) | ||
| if str(value).strip() | ||
| } | ||
| allowlist_names = { | ||
| str(value).strip().casefold() | ||
| for value in (getattr(settings, 'ESSENTIAL_ACADEMY_NAME_ALLOWLIST', []) or []) | ||
| if str(value).strip() | ||
| } | ||
| if allowlist_uuids or allowlist_names: | ||
| filtered_items = [] | ||
| for item in academy_items: | ||
| academy_uuid = str((item or {}).get('uuid') or '').strip() | ||
| academy_name = str((item or {}).get('name') or (item or {}).get('title') or '').strip().casefold() | ||
| if academy_uuid and academy_uuid in allowlist_uuids: | ||
| filtered_items.append(item) | ||
| continue | ||
| if academy_name and academy_name in allowlist_names: | ||
| filtered_items.append(item) | ||
| academy_items = filtered_items | ||
|
|
||
| default_catalog_query_uuid = None | ||
| if enterprise_customer_uuid: | ||
| catalogs_response = client.client.get( | ||
| urljoin(client.api_base_url, 'enterprise-catalogs/'), | ||
| params={'enterprise_customer': enterprise_customer_uuid}, | ||
| ) | ||
| catalogs_response.raise_for_status() | ||
| catalog_items = self._extract_results(catalogs_response.json()) | ||
| for catalog_item in catalog_items: | ||
| default_catalog_query_uuid = self._normalize_catalog_query_uuid(catalog_item.get('catalog_query_uuid')) | ||
| if default_catalog_query_uuid: | ||
| break |
| @backoff.on_exception(wait_gen=backoff.expo, exception=autoretry_for_exceptions) | ||
| def get_academies(self, academy_uuid=None): | ||
| """Fetch academies from enterprise-catalog.""" | ||
| path_template = getattr( | ||
| settings, | ||
| 'ACADEMY_ENTERPRISE_CATALOG_ACADEMIES_PATH', | ||
| 'academies/', | ||
| ) | ||
| endpoint = urljoin(self.api_base_url, path_template) | ||
| params = {} | ||
| if academy_uuid: | ||
| params['academy_uuid'] = str(academy_uuid) | ||
| response = self.client.get(endpoint, params=params or None) | ||
| response.raise_for_status() | ||
| return response.json() |
| user = models.ForeignKey( | ||
| User, | ||
| on_delete=models.CASCADE, | ||
| ) | ||
| stripe_product_id = models.CharField( | ||
| max_length=255, | ||
| null=True, | ||
| blank=True, | ||
| db_index=True, | ||
| help_text="Stripe Product ID for paid/academy intents. NULL for Teams intents.", | ||
| ) |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #179 +/- ##
==========================================
+ Coverage 85.98% 86.29% +0.30%
==========================================
Files 148 149 +1
Lines 12450 12765 +315
Branches 1185 1249 +64
==========================================
+ Hits 10705 11015 +310
- Misses 1428 1430 +2
- Partials 317 320 +3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
971ebb9 to
c68af8c
Compare
| def _sync_essential_academies(self): | ||
| enterprise_customer_uuid = (getattr(settings, 'ACADEMY_SYNC_ENTERPRISE_CUSTOMER_UUID', '') or '').strip() | ||
| client = EnterpriseCatalogApiV1Client() | ||
|
|
||
| academy_items = self._extract_results(client.get_academies()) | ||
|
|
||
| allowlist_uuids = { | ||
| str(value).strip() | ||
| for value in (getattr(settings, 'ESSENTIAL_ACADEMY_UUID_ALLOWLIST', []) or []) | ||
| if str(value).strip() | ||
| } | ||
| allowlist_names = { | ||
| str(value).strip().casefold() | ||
| for value in (getattr(settings, 'ESSENTIAL_ACADEMY_NAME_ALLOWLIST', []) or []) | ||
| if str(value).strip() | ||
| } | ||
| if allowlist_uuids or allowlist_names: | ||
| filtered_items = [] | ||
| for item in academy_items: | ||
| academy_uuid = str((item or {}).get('uuid') or '').strip() | ||
| academy_name = str((item or {}).get('name') or (item or {}).get('title') or '').strip().casefold() | ||
| if academy_uuid and academy_uuid in allowlist_uuids: | ||
| filtered_items.append(item) | ||
| continue | ||
| if academy_name and academy_name in allowlist_names: | ||
| filtered_items.append(item) | ||
| academy_items = filtered_items | ||
|
|
||
| default_catalog_query_id = None | ||
| if enterprise_customer_uuid: | ||
| catalog_items = self._extract_results( | ||
| client.get_catalogs(enterprise_customer_uuid=enterprise_customer_uuid) | ||
| ) | ||
| for catalog_item in catalog_items: | ||
| default_catalog_query_id = self._normalize_catalog_query_id(catalog_item.get('catalog_query_id')) | ||
| if default_catalog_query_id is not None: | ||
| break | ||
|
|
||
| seen_names = set() | ||
| for item in academy_items: | ||
| normalized = self._build_normalized_academy_payload(item, default_catalog_query_id) | ||
| if normalized is None: | ||
| continue | ||
|
|
||
| current = EnterpriseAcademy.objects.filter(name__iexact=normalized['name']).first() | ||
| seen_names.add(normalized['name']) | ||
|
|
||
| if current is None: | ||
| EnterpriseAcademy.objects.create(**normalized) | ||
| continue | ||
|
|
||
| has_changes = any( | ||
| getattr(current, field_name) != field_value | ||
| for field_name, field_value in normalized.items() | ||
| ) | ||
| if not has_changes: | ||
| continue | ||
|
|
||
| for field_name, field_value in normalized.items(): | ||
| setattr(current, field_name, field_value) | ||
| current.save(update_fields=[*normalized.keys(), 'modified']) | ||
|
|
||
| if seen_names: | ||
| EnterpriseAcademy.objects.filter(is_active=True).exclude(name__in=seen_names).update(is_active=False) | ||
|
|
||
| def _maybe_sync(self): | ||
| if self.request.query_params.get('sync') != 'true': | ||
| return | ||
| raise exceptions.ValidationError({'sync': ['sync=true is not supported on this public endpoint.']}) |
| academy_items = self._extract_results(client.get_academies()) | ||
|
|
||
| allowlist_uuids = { | ||
| str(value).strip() | ||
| for value in (getattr(settings, 'ESSENTIAL_ACADEMY_UUID_ALLOWLIST', []) or []) | ||
| if str(value).strip() | ||
| } | ||
| allowlist_names = { | ||
| str(value).strip().casefold() | ||
| for value in (getattr(settings, 'ESSENTIAL_ACADEMY_NAME_ALLOWLIST', []) or []) | ||
| if str(value).strip() | ||
| } | ||
| if allowlist_uuids or allowlist_names: | ||
| filtered_items = [] | ||
| for item in academy_items: | ||
| academy_uuid = str((item or {}).get('uuid') or '').strip() | ||
| academy_name = str((item or {}).get('name') or (item or {}).get('title') or '').strip().casefold() | ||
| if academy_uuid and academy_uuid in allowlist_uuids: | ||
| filtered_items.append(item) | ||
| continue | ||
| if academy_name and academy_name in allowlist_names: | ||
| filtered_items.append(item) | ||
| academy_items = filtered_items | ||
|
|
||
| default_catalog_query_id = None | ||
| if enterprise_customer_uuid: | ||
| catalog_items = self._extract_results( | ||
| client.get_catalogs(enterprise_customer_uuid=enterprise_customer_uuid) |
| normalized = { | ||
| 'name': name, | ||
| 'long_name': (item.get('long_name') or item.get('title') or name).strip(), | ||
| 'description': description.strip(), | ||
| 'marketing_url': (item.get('marketing_url') or '').strip(), | ||
| 'thumbnail_url': (item.get('thumbnail_url') or item.get('image') or '').strip(), | ||
| 'tags': item.get('tags') if isinstance(item.get('tags'), list) else [], | ||
| 'stripe_product_id': (item.get('stripe_product_id') or '').strip(), | ||
| 'stripe_price_lookup_key': stripe_lookup_key, | ||
| 'catalog_query_id': self._normalize_catalog_query_id(item.get('catalog_query_id')), | ||
| 'product_key': product_key[:255], | ||
| 'slug': slug[:255], | ||
| 'is_active': bool(item.get('is_active', True)), | ||
| 'display_order': int(item.get('display_order') or 0), | ||
| } | ||
|
|
||
| if normalized['catalog_query_id'] is None: | ||
| normalized['catalog_query_id'] = default_catalog_query_id | ||
|
|
||
| catalog_query_by_uuid = getattr(settings, 'ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_ACADEMY_UUID', {}) or {} | ||
| catalog_query_by_name = getattr(settings, 'ESSENTIAL_ACADEMY_CATALOG_QUERY_ID_BY_NAME', {}) or {} | ||
|
|
||
| mapped_catalog_query = catalog_query_by_uuid.get(academy_uuid) | ||
| if mapped_catalog_query is None: | ||
| mapped_catalog_query = catalog_query_by_name.get(name) | ||
| mapped_catalog_query_id = self._normalize_catalog_query_id(mapped_catalog_query) | ||
| if mapped_catalog_query_id is not None: | ||
| normalized['catalog_query_id'] = mapped_catalog_query_id | ||
|
|
||
| overrides_by_uuid = getattr(settings, 'ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_ACADEMY_UUID', {}) or {} | ||
| overrides_by_name = getattr(settings, 'ESSENTIAL_ACADEMY_FIELD_OVERRIDES_BY_NAME', {}) or {} | ||
| raw_override = overrides_by_uuid.get(academy_uuid) | ||
| if not isinstance(raw_override, dict): | ||
| raw_override = overrides_by_name.get(name) | ||
|
|
||
| if isinstance(raw_override, dict): | ||
| for key in ( | ||
| 'name', 'long_name', 'description', 'marketing_url', 'thumbnail_url', | ||
| 'stripe_product_id', 'stripe_price_lookup_key', 'product_key', 'slug', | ||
| ): | ||
| if key in raw_override and raw_override[key] is not None: | ||
| normalized[key] = str(raw_override[key]).strip() | ||
| if 'display_order' in raw_override: | ||
| try: | ||
| normalized['display_order'] = int(raw_override['display_order']) | ||
| except (TypeError, ValueError): | ||
| pass | ||
| if 'catalog_query_id' in raw_override: | ||
| override_catalog_query_id = self._normalize_catalog_query_id(raw_override['catalog_query_id']) | ||
| if override_catalog_query_id is not None: | ||
| normalized['catalog_query_id'] = override_catalog_query_id |
| def _get_catalog_query_id(self, academy): | ||
| for attr_name in ('catalog_query_id', 'catalog_query_int_id'): | ||
| value = getattr(academy, attr_name, None) | ||
| if isinstance(value, bool): | ||
| continue | ||
| if isinstance(value, int): | ||
| return value | ||
| if isinstance(value, str) and value.strip().isdigit(): | ||
| return int(value.strip()) | ||
| return None | ||
|
|
||
| def _serialize_price(self, price): | ||
| recurring = price.get('recurring') or {} | ||
| return { | ||
| 'id': price.get('id'), | ||
| 'product': (price.get('product') or {}).get('id'), | ||
| 'lookup_key': price.get('lookup_key'), | ||
| 'currency': price.get('currency'), | ||
| 'unit_amount': price.get('unit_amount'), | ||
| 'unit_amount_decimal': self._to_string_decimal(price.get('unit_amount_decimal')), | ||
| 'recurring': { | ||
| 'interval': recurring.get('interval'), | ||
| 'interval_count': recurring.get('interval_count'), | ||
| 'usage_type': recurring.get('usage_type'), | ||
| } if recurring else None, | ||
| } | ||
|
|
||
| def _serialize_academy(self, academy, all_prices_by_lookup): | ||
| lookup_key = academy.stripe_price_lookup_key | ||
| price_payload = all_prices_by_lookup.get(lookup_key) | ||
| serialized_prices = [self._serialize_price(price_payload)] if price_payload else [] | ||
| catalog_query_id = self._get_catalog_query_id(academy) | ||
| return { | ||
| 'id': self._academy_identifier(academy), | ||
| 'name': academy.name, | ||
| 'long_name': academy.long_name or academy.name, | ||
| 'description': academy.description or '', | ||
| 'marketing_url': academy.marketing_url or '', | ||
| 'thumbnail_url': self._resolve_thumbnail_url(academy.thumbnail_url or ''), | ||
| 'tags': academy.tags or [], | ||
| 'stripe_product_id': academy.stripe_product_id or '', | ||
| 'catalog_query_id': catalog_query_id, | ||
| 'edx_catalog_id': catalog_query_id, |
| if value is None: | ||
| return None | ||
| if isinstance(value, Decimal): | ||
| return f'{value:.2f}' |
| def retrieve(self, request, *args, **kwargs): | ||
| pk = kwargs.get('pk') | ||
| if not pk: | ||
| return Response(status=status.HTTP_404_NOT_FOUND) | ||
|
|
||
| try: | ||
| self._maybe_sync() | ||
| academies = list(self._get_academies_queryset()) | ||
| selected = next((academy for academy in academies if self._matches_pk(academy, pk)), None) | ||
| if not selected: | ||
| return Response(status=status.HTTP_404_NOT_FOUND) | ||
| all_prices_by_lookup = self._safe_get_all_stripe_prices() | ||
| except exceptions.APIException: | ||
| raise | ||
| except Exception as exc: # pylint: disable=broad-except | ||
| logger.exception('Unable to retrieve academy product %s: %s', pk, exc) | ||
| return Response( | ||
| {'error_code': 'academy_service_unavailable', 'detail': 'Retry later.'}, | ||
| status=status.HTTP_502_BAD_GATEWAY, | ||
| ) | ||
|
|
||
| payload = self._serialize_academy(selected, all_prices_by_lookup) | ||
| serializer = serializers.AcademyProductResponseSerializer(payload) | ||
| return Response(serializer.data, status=status.HTTP_200_OK) |
| academies = [ | ||
| academy | ||
| for academy in academies | ||
| if all(tag in (academy.tags or []) for tag in tags_filter) | ||
| ] |
302b228 to
830a518
Compare
213936c to
3867154
Compare
3867154 to
4afe15b
Compare
Description:
This PR adds and wires the academy-products API in the customer-billing surface, including endpoint registration, response serializers, and view logic for listing/retrieving academy products.
It also adds targeted API tests and required settings keys so the endpoint behavior and contract are validated end-to-end.
Changes
customer_billing.py
Added AcademyProductsViewSet behavior for list/retrieve, request filtering, and academy payload shaping.
Implemented catalog query field handling in responses and integrated endpoint-level safeguards.
customer_billing.py
Added academy product serializers, including nested price and recurring structures for response consistency.
Defined academy response fields used by the endpoint contract, including catalog query and pricing fields.
init.py
Exported the new academy product serializers from the central serializer package entrypoint.
Ensures view imports resolve through the standard serializer module path.
urls.py
Registered the academy-products route in the v1 router when customer billing APIs are enabled.
Makes the endpoint available through the existing API version namespace and routing pattern.
init.py
Exported AcademyProductsViewSet in the v1 views index module.
Keeps viewset discovery and router wiring aligned with existing import conventions.
test_customer_billing.py
Added and updated academy-products tests for public access, sync/query behavior, filters, error paths, and payload field expectations.
base.py
Added settings used by the academy-products flow, including throttle rate key and academy-related configuration defaults.
Included endpoint support settings such as thumbnail base URL and academy configuration maps used by view logic.