feat: ENT-11468-PR : B- Updated Interfaces: external clients#172
feat: ENT-11468-PR : B- Updated Interfaces: external clients#172tsunkara-sonata wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds new EnterpriseCatalogApiClient v2 methods to fetch academies and enterprise catalogs from enterprise-catalog, along with unit tests covering the new endpoints. This supports the broader Essential Academy integration work referenced in PR #168.
Changes:
- Add
get_academies(academy_uuid=None)andget_catalogs()to the enterprise-catalog v2 API client. - Add tests for the new client methods and their request URL/params behavior.
No critical issues found.
Non-critical issues to address:
- New tests include unused
request_responsevariables (dead code). - “compatibility wrapper” tests don’t actually validate any wrapper/alias behavior (they only check
hasattr).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| enterprise_access/apps/api_client/enterprise_catalog_client.py | Adds v2 client methods for academies and enterprise catalog listing. |
| enterprise_access/apps/api_client/tests/test_enterprise_catalog_client.py | Adds unit tests for the new client methods (URL/params assertions). |
Comments suppressed due to low confidence (3)
enterprise_access/apps/api_client/tests/test_enterprise_catalog_client.py:147
request_responseis instantiated and modified but never used; the test currently relies on the mock's return value instead. Either removerequest_responseor setmock_oauth_client.return_value.get.return_value = request_responseto avoid dead code and make the test intent clearer.
mock_response_json = {'results': []}
request_response = Response()
request_response.status_code = 200
mock_oauth_client.return_value.get.return_value.json.return_value = mock_response_json
enterprise_access/apps/api_client/tests/test_enterprise_catalog_client.py:164
request_responseis unused in this test (it’s not set as the.get()return value). Removing it (or wiring it up as the return value) will prevent dead code and make the test assertions more meaningful.
mock_response_json = {'results': [{'uuid': str(uuid4())}]}
request_response = Response()
request_response.status_code = 200
mock_oauth_client.return_value.get.return_value.json.return_value = mock_response_json
enterprise_access/apps/api_client/tests/test_enterprise_catalog_client.py:186
- Similar to the academies compat test: this only checks
hasattr(client, 'get_catalogs'), which doesn’t actually verify a legacy compatibility wrapper. Consider renaming the test or asserting behavior of the intended legacy entrypoint (if one exists/should exist).
def test_compat_get_enterprise_customer_catalogs(self, _mock_oauth_client):
"""Test that the compatibility wrapper method works."""
client = EnterpriseCatalogApiClient()
# This tests the legacy compatibility wrapper
self.assertTrue(hasattr(client, 'get_catalogs'))
| mock_response_json = {'results': [{'title': 'Artificial Intelligence'}]} | ||
| request_response = Response() | ||
| request_response.status_code = 200 | ||
| mock_oauth_client.return_value.get.return_value.json.return_value = mock_response_json |
Codecov Report❌ Patch coverage is
❌ Your patch check has failed because the patch coverage (93.93%) is below the target coverage (95.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #172 +/- ##
==========================================
+ Coverage 86.08% 86.10% +0.02%
==========================================
Files 149 149
Lines 12500 12533 +33
Branches 1194 1199 +5
==========================================
+ Hits 10761 10792 +31
- Misses 1424 1425 +1
- Partials 315 316 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
approved |
| path_template = getattr( | ||
| settings, | ||
| 'ACADEMY_ENTERPRISE_CATALOG_ACADEMIES_PATH', | ||
| 'academies/', | ||
| ) | ||
| endpoint = urljoin(self.api_base_url, path_template) |
There was a problem hiding this comment.
❌ Never use fallback values when fetching a setting. Fallback values should only be configured in enterprise_access/settings/base.py. That said, in this case no fallback is needed, you can simply hard-code the path.
❌ Furthermore, please follow the established pattern in this class of constructing the endpoints within the init() function.
Addressing the above two concerns would result in init() with one extra line:
self.academies_endpoint = urljoin(self.api_base_url, 'academies/')and this code would change to:
| path_template = getattr( | |
| settings, | |
| 'ACADEMY_ENTERPRISE_CATALOG_ACADEMIES_PATH', | |
| 'academies/', | |
| ) | |
| endpoint = urljoin(self.api_base_url, path_template) | |
| endpoint = urljoin(self.api_base_url, self.academies_endpoint) |
Finally, ❌ this endpoint will likely be paginated, and this method as written has no accommodations for paging. Without knowing how you intend to use this method, I'll just leave it to you to decide how to support pagination.
| raise NotImplementedError('There is currently no v2 API implementation for this endpoint.') | ||
|
|
||
| @backoff.on_exception(wait_gen=backoff.expo, exception=autoretry_for_exceptions) | ||
| def get_academies(self, academy_uuid=None): |
There was a problem hiding this comment.
Please type hint both function signatures.
|
|
||
| @backoff.on_exception(wait_gen=backoff.expo, exception=autoretry_for_exceptions) | ||
| def get_academies(self, academy_uuid=None): | ||
| """Fetch academies from enterprise-catalog.""" |
There was a problem hiding this comment.
Describe the shape of the return value in this docstring.
| path_template = getattr( | ||
| settings, | ||
| 'ACADEMY_ENTERPRISE_CATALOG_CATALOGS_PATH', | ||
| 'enterprise-catalogs/', | ||
| ) | ||
| endpoint = urljoin(self.api_base_url, path_template) | ||
| response = self.client.get(endpoint) |
There was a problem hiding this comment.
❌ This endpoint string is redundant with self.enterprise_catalog_endpoint which is already available on the class.
| path_template = getattr( | |
| settings, | |
| 'ACADEMY_ENTERPRISE_CATALOG_CATALOGS_PATH', | |
| 'enterprise-catalogs/', | |
| ) | |
| endpoint = urljoin(self.api_base_url, path_template) | |
| response = self.client.get(endpoint) | |
| response = self.client.get(self.enterprise_catalog_endpoint) |
| path_template = getattr( | ||
| settings, | ||
| 'ACADEMY_ENTERPRISE_CATALOG_CATALOGS_PATH', | ||
| 'enterprise-catalogs/', | ||
| ) | ||
| endpoint = urljoin(self.api_base_url, path_template) | ||
| response = self.client.get(endpoint) |
There was a problem hiding this comment.
❌ Without filter params, this API call will fetch ALL catalogs, which is almost guaranteed to be paginated. Yet, this function does not handle a paginated output, so it will only return the first page. If you planned to paginate in the caller, that still won't work because there's no way to pass the page number to this method.
It would be helpful if you also described the use case of this method in the docstring.
a7a1284 to
4e78bb2
Compare
7ae69d1 to
c280fb6
Compare
| @backoff.on_exception(wait_gen=backoff.expo, exception=autoretry_for_exceptions) | ||
| def get_academies(self, academy_uuid: str | None = None) -> dict: | ||
| """ | ||
| Fetch academies for Essentials flows from enterprise-catalog. | ||
|
|
||
| Returns: | ||
| dict: Paginated response shape with keys including count/next/previous/results. | ||
| If the endpoint paginates, all pages are merged into a single response payload. | ||
| """ | ||
| params = {'academy_uuid': str(academy_uuid)} if academy_uuid else None | ||
| return self._fetch_all_pages(self.academies_endpoint, params=params) | ||
|
|
||
| @backoff.on_exception(wait_gen=backoff.expo, exception=autoretry_for_exceptions) | ||
| def get_catalogs(self, enterprise_customer_uuid: str | None = None) -> dict: | ||
| """ | ||
| Fetch enterprise catalogs, optionally scoped to an enterprise customer UUID. | ||
|
|
||
| Returns: | ||
| dict: Paginated response shape with keys including count/next/previous/results. | ||
| If the endpoint paginates, all pages are merged into a single response payload. | ||
| """ | ||
| params = {'enterprise_customer': str(enterprise_customer_uuid)} if enterprise_customer_uuid else None | ||
| return self._fetch_all_pages(self.enterprise_catalog_endpoint, params=params) | ||
|
|
pwnage101
left a comment
There was a problem hiding this comment.
Looking much better! Still a few more minor issues.
| merged_results = [] | ||
| next_url = endpoint | ||
| request_params = params | ||
| base_payload = None |
There was a problem hiding this comment.
❌ Please refer to already established patterns in this same repo:
There's no need for the return value of _fetch_all_pages to be a dict. The metadata in the response payload surrorunding the results list is only needed for pagination, and the purpose of this function is to abstract away the pagination.
| return response.json()['count'] | ||
|
|
||
| @backoff.on_exception(wait_gen=backoff.expo, exception=autoretry_for_exceptions) | ||
| def get_academies(self, academy_uuid: str | None = None) -> dict: |
There was a problem hiding this comment.
Nit: Is there really a use case for passing academy_uuid? Doesn't this function serve the broader use case of a scheduled sync job? It's possible we could simplify by removing the argument:
| def get_academies(self, academy_uuid: str | None = None) -> dict: | |
| def get_academies(self) -> list: |
| self.enterprise_catalog_endpoint = urljoin(self.api_base_url, 'enterprise-catalogs/') | ||
| super().__init__() | ||
|
|
||
| def _fetch_all_pages(self, endpoint, params=None) -> dict: |
There was a problem hiding this comment.
❌ should return list
| def _fetch_all_pages(self, endpoint, params=None) -> dict: | |
| def _fetch_all_pages(self, endpoint, params=None) -> list: |
| dict: Paginated response shape with keys including count/next/previous/results. | ||
| If the endpoint paginates, all pages are merged into a single response payload. | ||
| """ | ||
| params = {'enterprise_customer': str(enterprise_customer_uuid)} if enterprise_customer_uuid else None |
There was a problem hiding this comment.
❌ There's no point in converting to str if the type hint already makes str | None explicit.
| params = {'enterprise_customer': str(enterprise_customer_uuid)} if enterprise_customer_uuid else None | |
| params = {'enterprise_customer': enterprise_customer_uuid} if enterprise_customer_uuid else None |
Description:
Add a description of your changes here.
Part of #168
Interfaces: external clients
[enterprise_access/apps/api_client/enterprise_catalog_client.py]—get_academies(),get_catalogs().
[enterprise_access/apps/api_client/tests/test_enterprise_catalog_client.py]