Skip to content

11468 pr f- Academy products API Endpoint#179

Open
vshaikismail-sonata wants to merge 3 commits into
mainfrom
11468-PR-F
Open

11468 pr f- Academy products API Endpoint#179
vshaikismail-sonata wants to merge 3 commits into
mainfrom
11468-PR-F

Conversation

@vshaikismail-sonata

Copy link
Copy Markdown
Contributor

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

  1. 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.

  2. 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.

  3. init.py
    Exported the new academy product serializers from the central serializer package entrypoint.
    Ensures view imports resolve through the standard serializer module path.

  4. 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.

  5. init.py
    Exported AcademyProductsViewSet in the v1 views index module.
    Keeps viewset discovery and router wiring aligned with existing import conventions.

  6. test_customer_billing.py
    Added and updated academy-products tests for public access, sync/query behavior, filters, error paths, and payload field expectations.

  7. 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.

Copilot AI review requested due to automatic review settings May 25, 2026 20:55
@vshaikismail-sonata vshaikismail-sonata requested review from a team as code owners May 25, 2026 20:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 optional sync=true that pulls academies/catalogs from enterprise-catalog and upserts EnterpriseAcademy rows.
  • CheckoutIntent.user changed from OneToOneFieldForeignKey, added stripe_product_id, two partial unique constraints scoped to active states, and a richer for_user(...) lookup.
  • Migration renaming enterprise_catalog_uuidcatalog_query_id, new academy serializers, settings keys (throttle rate, S3 thumbnail base, sync allowlists/overrides), and reusable EnterpriseCatalogApiV1Client.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.

Comment on lines +397 to +400
def _maybe_sync(self):
if self.request.query_params.get('sync') != 'true':
return
self._sync_essential_academies()
Comment on lines +422 to +427
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,
)
Comment on lines +90 to +97
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())
Comment on lines +326 to +368
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
Comment on lines +98 to +112
@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()
Comment on lines +243 to +253
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

codecov Bot commented May 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.86466% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.29%. Comparing base (8c80c1d) to head (9bf98ca).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
...prise_access/apps/api/v1/views/customer_billing.py 95.35% 6 Missing and 5 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI review requested due to automatic review settings May 26, 2026 09:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

Comment on lines +305 to +373
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.']})
Comment on lines +309 to +336
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)
Comment on lines +251 to +301
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
Comment on lines +89 to +131
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}'
Comment on lines +419 to +442
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)
Comment on lines +190 to +194
academies = [
academy
for academy in academies
if all(tag in (academy.tags or []) for tag in tags_filter)
]
Copilot AI review requested due to automatic review settings May 26, 2026 10:55
@vshaikismail-sonata vshaikismail-sonata force-pushed the 11468-PR-F branch 3 times, most recently from 213936c to 3867154 Compare May 26, 2026 11:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants