feat: filter academies by actual content in enterprise catalog (ENT-11220)#73
feat: filter academies by actual content in enterprise catalog (ENT-11220)#73sjasti-sonata-svg wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Updates the Academies API so academies are only listed for an enterprise customer when there is matching content in that enterprise’s catalog query, preventing “empty” academies from appearing to learners.
Changes:
- Add a content-existence filter in
AcademiesReadOnlyViewSet.get_queryset()that checksContentMetadatalinked via academy tags and the enterprise’s catalog query. - Update
AcademiesViewSetTestssetup to createContentMetadatawired to tags + catalog query to satisfy the new filter. - Refresh/pin a wide set of Python dependencies across environment requirement files.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| requirements/validation.txt | Dependency pin updates for validation tooling environment. |
| requirements/test.txt | Dependency pin updates for test environment. |
| requirements/quality.txt | Dependency pin updates for lint/quality environment. |
| requirements/production.txt | Dependency pin updates for production environment. |
| requirements/pip.txt | Regenerated pip requirements metadata/comments. |
| requirements/pip-tools.txt | Update build dependency used by pip-tools workflow. |
| requirements/doc.txt | Dependency pin updates for docs build environment. |
| requirements/django.txt | Bump pinned Django patch version. |
| requirements/dev.txt | Dependency pin updates for dev environment. |
| requirements/common_constraints.txt | Removes a previously-global pip constraint. |
| requirements/base.txt | Core dependency pin updates shared by other requirement sets. |
| enterprise_catalog/apps/api/v1/views/academies.py | Adds “actual content exists in enterprise catalog” gating for academy listing. |
| enterprise_catalog/apps/api/v1/tests/test_views.py | Adjusts academies view tests to create related content metadata for the new gating. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 'catalog_query_id', flat=True, | ||
| ) | ||
| if academy_content.filter(catalog_queries__in=catalog_query_ids).exists(): | ||
| user_accessible_academy_uuids.append(academy.uuid) |
There was a problem hiding this comment.
The current implementation does two DB hits per academy (truthiness evaluation of enterprise_associated_catalogs plus the ContentMetadata...exists() check), resulting in an N+1 query pattern that will scale poorly as the number of academies grows. Consider rewriting this to use a single ORM query (joins/subquery) that directly returns academies with (1) an associated EnterpriseCatalog for the given enterprise_uuid and (2) at least one related ContentMetadata matching those catalogs’ catalog_query IDs, using .exists() where appropriate instead of relying on queryset truthiness.
There was a problem hiding this comment.
The number of academies is small in practice, so the N+1 pattern has negligible impact here. We can optimize this in a follow-up if needed.
| # Verify the academy has actual content in the enterprise catalog, | ||
| # not just a catalog association. | ||
| academy_content = ContentMetadata.objects.filter(tags__academies=academy) | ||
| catalog_query_ids = enterprise_associated_catalogs.values_list( | ||
| 'catalog_query_id', flat=True, | ||
| ) | ||
| if academy_content.filter(catalog_queries__in=catalog_query_ids).exists(): | ||
| user_accessible_academy_uuids.append(academy.uuid) |
There was a problem hiding this comment.
The new “content exists” check does not constrain ContentMetadata to course records (e.g., content_type=COURSE). As written, a single program/pathway (or even a course run) tagged to the academy and present in the catalog query could cause the academy to appear, which may not satisfy the ticket’s requirement of “Academy courses in the subscription catalog.” Please narrow the filter to the intended content types (and update the test setup accordingly).
There was a problem hiding this comment.
Good catch. Updated to filter by content_type=COURSE to match the AC requirement of "Academy courses in the subscription catalog."
| # Create content metadata linked to academy tags and catalog query | ||
| # so that the academy passes the content existence check. | ||
| self.content_metadata = ContentMetadataFactory() | ||
| self.content_metadata.catalog_queries.add(self.enterprise_catalog_query) | ||
| self.tag1.content_metadata.add(self.content_metadata) |
There was a problem hiding this comment.
The tests were updated to create content that satisfies the new check, but they still don’t cover the key negative case from the acceptance criteria: an academy is associated to an enterprise catalog but has no matching course content in that catalog query and therefore should be excluded. Add an explicit test asserting the list endpoint returns an empty set (or excludes that academy) in this scenario. Also, ContentMetadataFactory() cycles content types starting with COURSE_RUN; to avoid accidental pass/fail depending on future filtering, create course metadata explicitly (e.g., content_type=COURSE) for this setup.
There was a problem hiding this comment.
Added a negative test case (test_list_excludes_academy_without_course_content) that verifies an academy with catalog association but no matching course content is excluded. Also updated the existing setup to explicitly use content_type=COURSE.
| @@ -30,13 +30,13 @@ backoff==1.10.0 | |||
| # via analytics-python | |||
| billiard==4.2.4 | |||
| # via celery | |||
| celery==5.6.0 | |||
| celery==5.6.2 | |||
| # via | |||
| # -c requirements/constraints.txt | |||
| # -r requirements/base.in | |||
| # django-celery-results | |||
| # edx-celeryutils | |||
| certifi==2025.11.12 | |||
| certifi==2026.1.4 | |||
There was a problem hiding this comment.
This PR includes a broad dependency refresh across multiple requirements files (e.g., Django/celery/pylint/etc. bumps), but the PR description focuses solely on academy filtering behavior. Please either (a) update the PR description/test plan to explicitly call out the dependency upgrade rationale/validation, or (b) split the dependency changes into a separate PR to keep review scope and rollback risk manageable.
There was a problem hiding this comment.
We have not updated any requirements files in this PR. The dependency changes are from a separate commit on the base branch (commit 4cd5bf0 - "chore: Upgrade Python requirements") that was merged independently. This PR only touches views/academies.py and tests/test_views.py.
9071d22 to
9006671
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #73 +/- ##
==========================================
+ Coverage 85.28% 85.29% +0.01%
==========================================
Files 101 101
Lines 5613 5617 +4
Branches 670 670
==========================================
+ Hits 4787 4791 +4
Misses 699 699
Partials 127 127 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -54,7 +56,17 @@ def get_queryset(self): | |||
| enterprise_uuid=enterprise_customer | |||
| ) | |||
| if enterprise_associated_catalogs: | |||
| user_accessible_academy_uuids.append(academy.uuid) | |||
| # Verify the academy has actual content in the enterprise catalog, | |||
| # not just a catalog association. | |||
| academy_content = ContentMetadata.objects.filter( | |||
| tags__academies=academy, | |||
| content_type=COURSE, | |||
| ) | |||
| catalog_query_ids = enterprise_associated_catalogs.values_list( | |||
| 'catalog_query_id', flat=True, | |||
| ) | |||
| if academy_content.filter(catalog_queries__in=catalog_query_ids).exists(): | |||
There was a problem hiding this comment.
enterprise_associated_catalogs is being evaluated for truthiness, which triggers a DB query, and then you immediately run additional queries (values_list + exists()). You can drop the if enterprise_associated_catalogs: guard and rely on the final .exists() check (an empty catalog_query_ids subquery will naturally yield no matches), which avoids one query per academy without changing behavior.
There was a problem hiding this comment.
Fixed. Removed the if enterprise_associated_catalogs: guard and consolidated into a single .exists() query.
| # Create an academy with a catalog association but no content metadata | ||
| academy_no_content = AcademyFactory() | ||
| tag_no_content = TagFactory(title='empty-tag') |
There was a problem hiding this comment.
TagFactory automatically creates and attaches ContentMetadata records in its post_generation hook, so tag_no_content (and therefore academy_no_content) actually has course content metadata. This makes the inline comment/docstring inaccurate and makes the test less explicit about what it’s validating. Consider creating the Tag directly (or clearing tag_no_content.content_metadata) so the test deterministically represents an academy with a catalog association but no course content.
| # Create an academy with a catalog association but no content metadata | |
| academy_no_content = AcademyFactory() | |
| tag_no_content = TagFactory(title='empty-tag') | |
| # Create an academy with a catalog association but explicitly no content metadata. | |
| academy_no_content = AcademyFactory() | |
| tag_no_content = TagFactory(title='empty-tag') | |
| tag_no_content.content_metadata.clear() |
There was a problem hiding this comment.
Good catch. Added tag_no_content.content_metadata.clear() so the test deterministically represents an academy with no course content.
00dd12f to
7a0937c
Compare
…1220) Verify that an academy has actual course content in the enterprise's catalog before showing it. Previously, only catalog association was checked, which could show empty academies to learners. - Filter ContentMetadata by content_type=COURSE to match AC - Add negative test for academy exclusion when no course content exists
7a0937c to
5dfb8f9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Verify the academy has actual course content in the enterprise catalog. | ||
| catalog_query_ids = enterprise_associated_catalogs.values_list( | ||
| 'catalog_query_id', flat=True, | ||
| ) | ||
| has_content = ContentMetadata.objects.filter( | ||
| tags__academies=academy, | ||
| content_type=COURSE, | ||
| catalog_queries__in=catalog_query_ids, | ||
| ).exists() | ||
| if has_content: |
There was a problem hiding this comment.
I don't understand why this change is needed at all - if the given customer has a catalog that's associated with the academy, then that academy should be surfaced to the customer's users. Can you describe how to re-create the issue that this pull request is attempting to fix?
There was a problem hiding this comment.
From a quick chat with copilot:
The scenario the PR describes — "catalog is associated but has no matching course content" — sounds like a data integrity problem, not something the academies API should be papering over at query time.
A few specific concerns:
The catalog association is the contract. An EnterpriseCatalog links an enterprise to a CatalogQuery. If the academy is tied to that catalog, the expectation is that the catalog's query is set up to include the academy's courses. If it doesn't, that's a misconfiguration of the catalog or the academy — not something we should silently hide.Performance cost in the hot path. The new code runs a ContentMetadata query with joins across tags, academies, and catalog_queries for every academy, on every list request. That's a meaningful N+1 situation in what's currently a simple filter.
It masks bad data. If an academy is associated with a catalog that doesn't actually contain relevant courses, we probably want to know about that — it likely means something went wrong in the catalog setup or content sync. Silently hiding the academy makes that harder to diagnose.
|
@sjasti-sonata-svg is this PR still needed? If not, we can close it. Thanks! |
Context
As part of ENT-11220, Academies is being enabled by default for all Teams and Enterprise Subscription customers with search enabled.
Acceptance Criteria (ENT-11220):
This PR is mandatory to fulfill the above AC. Without it, the ticket is incomplete.
Why This Change Is Required
The Problem (without this change)
The current academies API (
AcademiesReadOnlyViewSet.get_queryset) only checks if an academy's catalog is associated with the enterprise customer:This means: if an academy's catalog is linked to an enterprise but the catalog does not actually contain the academy's courses, the learner still sees the academy tile. They click it and find zero or partial courses — a broken experience that violates the AC.
The Fix (this PR)
We now verify that the academy has actual course content in the enterprise's catalog before showing it:
Data flow:
Files Changed
views/academies.py(line 12)ContentMetadataimportviews/academies.py(lines 49-55)tests/test_views.py(lines 2072-2074)ContentMetadatato test setupWhat Happens Without This PR
Related PR
True+ data migration for existing customers)Ticket
https://2u-internal.atlassian.net/browse/ENT-11220
Test plan
AcademiesViewSetTests)