Exclude Courses in Search, Resume, Library & plugin_data flag for courses#14132
Conversation
c2510f5 to
6c68b96
Compare
Build Artifacts
Smoke test screenshot |
e56a431 to
09fe9d0
Compare
rtibbles
left a comment
There was a problem hiding this comment.
Blocking:
- Missing
exclude_course_ancestryin initial resume fetch and hydration endpoint - Cache bug in
get_course_idswhen there are no courses on the device valueparameter not checked infilter_exclude_course_ancestry
Suggestions:
- Stale docstring on the filter method
This PR review was initially generated by Claude Code and then reviewed, supplemented, and edited by @rtibbles.
|
|
||
| export function setResumableContentNodes(nodes, more = null) { | ||
| if (more) { | ||
| more = { ...more, exclude_course_ancestry: true }; |
There was a problem hiding this comment.
blocking: The exclude_course_ancestry: true param is only injected into the more pagination object here, but the initial fetch at line 280 uses { resume: true, max_results: 12, ordering: '-last_interacted' } without it. This means the first page of resume results is fetched without course ancestry exclusion — only subsequent pages get the filter.
This is a workaround because the initial resume call is not getting it properly set on it.
The same issue exists in the server-side hydration endpoint (LearnHomePageHydrationView in viewsets.py ~line 349) which also calls serialize_list with {"resume": True, "max_results": 12, "ordering": "-last_interacted"} and no exclude_course_ancestry.
Suggestion: add exclude_course_ancestry: true to the initial params in fetchResumableContentNodes() at line 280, and to the hydration view's params dict here: https://github.com/learningequality/kolibri/blob/release-v0.19.x/kolibri/plugins/learn/viewsets.py#L356
kolibri/core/content/api.py
Outdated
| def get_course_ids(): | ||
| cache_key = "COURSES_IDS_{}".format(get_cache_key()) | ||
| cached_data = cache.get(cache_key) | ||
| if cached_data: |
There was a problem hiding this comment.
blocking: if cached_data: is falsy for an empty queryset (no courses on device). This means when there are no courses, the DB query will be re-executed on every call instead of returning the cached empty result, defeating the cache.
Consider using a sentinel pattern or checking is not None:
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data| model = models.ContentNode | ||
| fields = contentnode_filter_fields | ||
|
|
||
| def filter_exclude_course_ancestry(self, queryset, name, value): |
There was a problem hiding this comment.
blocking: The value parameter is not checked. Since this is a BooleanFilter, passing exclude_course_ancestry=false will still trigger the exclusion. The filter should be conditional on value:
def filter_exclude_course_ancestry(self, queryset, name, value):
if not value:
return queryset
...Currently the filter is always called with true from the frontend, but since this is a public API filter, it should handle false correctly.
| identifier=identifier, baseurl=baseurl, keyword=keyword, language=language | ||
| ) | ||
| try: | ||
|
|
rtibblesbot
left a comment
There was a problem hiding this comment.
Clean implementation of course exclusion from Library search, channel browsing, and resume recommendations. The two-filter approach (exclude_modalities for course nodes, exclude_course_ancestry for descendants) is well-designed, giving each call site the right level of filtering.
CI passing across all Python versions and builds. No new i18n strings added (appropriate for release-v0.19.x target). UI changes are logic-only (no template/style changes), so no visual verification needed.
- suggestion:
get_course_ids()caches a queryset — consider materializing tolist()for predictable caching behavior - suggestion: Backend test for
filter_exclude_course_ancestry(as the author noted) - nitpick: Unrelated
filter_exclude_content_idsguard mixed in
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Reviewed the pull request diff checking for:
- Correctness: bugs, edge cases, undocumented behavior, resource leaks, hardcoded values
- Design: unnecessary complexity, naming, readability, comment accuracy, redundant state
- Architecture: duplicated concerns, minimal interfaces, composition over inheritance
- Testing: behavior-based assertions, mocks only at hard boundaries, accurate coverage
- Completeness: missing dependencies, unupdated usages, i18n, accessibility, security
- Principles: DRY (same reason to change), SRP, Rule of Three (no premature abstraction)
- Checked CI status and linked issue acceptance criteria
- For UI changes: inspected screenshots for layout, visual completeness, and consistency
|
|
||
| export function setResumableContentNodes(nodes, more = null) { | ||
| if (more) { | ||
| more = { ...more, exclude_course_ancestry: true }; |
There was a problem hiding this comment.
suggestion: Minor: the more object comes from the API and already reflects the server's pagination state. Injecting exclude_course_ancestry: true here ensures it's present for fetchMoreResumableContentNodes, which is correct — but this coupling means the client is responsible for remembering to add the filter to paginated requests. If the server's more response already included this param (since the original request had it), this client-side injection wouldn't be needed. Worth verifying whether the API's more object already carries forward the original query params.
There was a problem hiding this comment.
Note this is the same thing I flagged previously here: #14132 (comment) we shouldn't have to inject this here, because it should already have come from the API request.
dcf4ee4 to
71b7f03
Compare
rtibblesbot
left a comment
There was a problem hiding this comment.
Delta review — 1 prior finding resolved (get_course_ids now materializes to list()).
CI: Morango Integration Tests still in progress; all other checks passing.
Prior unresolved:
- [suggestion] Backend test for
filter_exclude_course_ancestry— UNRESOLVED (author acknowledged this would be good to add; still missing) - [nitpick] Unrelated
filter_exclude_content_idsguard — UNRESOLVED (defensive fix is fine but unrelated to this PR)
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNRESOLVED, PARTIALLY_ADDRESSED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
|
Thanks for the review, taking a look now. |
rtibblesbot
left a comment
There was a problem hiding this comment.
Delta review — 2 prior finding(s) resolved (backend tests added; list() materialization already done).
CI passing. No new i18n strings. UI changes are logic-only.
Prior unresolved:
- [nitpick] Unrelated
filter_exclude_content_idsguard — UNRESOLVED (still mixed in; minor)
New finding:
- [suggestion]
get_course_ids()missingavailable=Truefilter — see inline comment
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNRESOLVED, PARTIALLY_ADDRESSED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
kolibri/core/content/api.py
Outdated
| if cached_data is not None: | ||
| return cached_data | ||
| updated_data = list( | ||
| ContentNode.objects.filter(modality=modalities.COURSE).values_list( |
There was a problem hiding this comment.
suggestion: get_course_ids() queries all course nodes regardless of availability, but courses_exist in kolibri_plugin.py:64 filters available=True. If an unavailable course node exists (e.g. partial import/removal), its available descendants would still be excluded from search and resume results — even though the course itself isn't accessible to learners.
Consider adding available=True:
ContentNode.objects.filter(available=True, modality=modalities.COURSE).values_list(...)This aligns the filtering with the courses_exist flag and avoids excluding descendants when the course itself isn't active on the device.
There was a problem hiding this comment.
If an unavailable course node exists (e.g. partial import/removal), its available descendants would still be excluded from search and resume results — even though the course itself isn't accessible to learners.
I reckon this is desired anyhow - am I misunderstanding @rtibbles ?
There was a problem hiding this comment.
That's a fair point — if the course was partially imported, you'd still want its descendants excluded from search/resume since they belong to an incomplete course. Withdrawing this suggestion.
There was a problem hiding this comment.
It's probably still worth filtering, because a topic is only unavailable if all its descendants are unavailable. So we can filter by available is True because we are generally filtering by available True anyway, so we would still be excluding all its descendants, and gives us one fewer courses to filter against here.
There was a problem hiding this comment.
Good point — thanks for clarifying. So the available=True filter is worthwhile as a minor optimization since unavailable topics already imply all descendants are unavailable. Glad the suggestion holds up after all.
There was a problem hiding this comment.
Pushed it up thanks Tibbles'
rtibblesbot
left a comment
There was a problem hiding this comment.
Delta review — 3 prior finding(s) resolved.
CI: Build Assets check in progress; prior checks were passing.
Prior unresolved:
- [nitpick] Unrelated
filter_exclude_content_idsguard — UNADDRESSED (still mixed in; too minor to keep raising)
No new findings. Implementation is clean — the two-filter approach, caching, tests, and consistent application across search/resume/channel browsing all look solid. Approving.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Compared the current PR state against findings from a prior review:
- Retrieved prior bot reviews deterministically via the GitHub API
- Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
- Only raised NEW findings for newly introduced code
- Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
- Checked CI status and linked issue acceptance criteria
rtibbles
left a comment
There was a problem hiding this comment.
I think it would be prudent to get some manual QA on this just to be sure!
|
Hi @nucleogenesis - I confirm everything mentioned in the QA guidance except that the courses which I had assigned to the learner (and had marked some of the resources as completed) are still visible at not.excluded.mp4Also flagging, as it's not completely clear that as a superadmin user I am still seeing the Courses tab and can access it at |
489f877 to
8a9892e
Compare
|
@pcenov I've pushed changes that only hides courses from Learners in the Learn library However:
This might have been due to my needing to rebase the PR to pull in other changes - I tried to replicate the 500 error and the assigned course opened up as expected. (cc @rtibbles) |
|
Thanks @nucleogenesis - I confirm that a learner who had an assigned course can see the course card in the Recent courses section and can access the course content. Also such a learner can't see any courses at the Library page: learner.with.progress.mp4Unfortunately for some reason users of type coach, admin and super admin still can't see the courses at the Library page: admin.mp4 |
|
@pcenov sorry for the oversight - I only fixed the search part 😓 I've pushed an update to also only exclude course content for learners in the Library. |
|
Thanks @nucleogenesis - I confirm that the courses are now visible at the Library page for coaches, admins and super admins and not visible to learners. Noticed that if "Explore without account" is visible a user who's not signed in can see the courses at the Library. I guess there's no harm in that, right? explore.mp4 |
|
@pcenov I've pushed an update to exclude courses from not-logged-in users as well - not an urgent item to QA but would good to get another quick pass at your convenience. |
|
|
||
| function createBaseSearchGetParams() { | ||
| const getParams = { | ||
| exclude_modalities: get(isLearner) ? Modalities.COURSE : null, |
There was a problem hiding this comment.
This and below are still using isLearner.
| get(isAdmin) || get(isCoach) || get(isSuperuser) ? null : Modalities.COURSE, | ||
| exclude_course_ancestry: !(get(isAdmin) || get(isCoach) || get(isSuperuser)), | ||
| include_coach_content: get(isAdmin) || get(isCoach) || get(isSuperuser), | ||
| baseurl: get(baseurl), |
There was a problem hiding this comment.
Claude noticed that the previous implementation would result in there being baseurl: undefined in the params due to the test outputs.
I think Claude noticed the baseurl: undefined in the diff and figured it was related to the test failures or maybe just saw "undefined value in params is bad" but the change made sense to me to avoid sending undefined values.
There was a problem hiding this comment.
Right, but undefined params don't get sent, so this is fine.
f313b71 to
3e8f41b
Compare
rtibbles
left a comment
There was a problem hiding this comment.
Code changes check out, and most importantly manual QA is approving!
Add a cached lookup of course IDs and a new boolean filter that excludes content nodes descended from Course-modality nodes. Includes backend tests for both the true and false cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add courses_exist flag to plugin_data. Pass exclude_course_ancestry=True when fetching resumable resources in the hydration view so learners do not see course-internal content outside of courses. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add exclude_modalities and exclude_course_ancestry params to search requests for non-admin/coach/superuser roles. Hide Course-modality nodes from the topics page for learners. Update useBaseSearch tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pass exclude_course_ancestry when fetching resumable content nodes. Add course-related computed properties and helpers to useLearnerResources. Update tests for fetchMoreResumableContentNodes and related behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3e8f41b to
be5d0b6
Compare
rtibbles
left a comment
There was a problem hiding this comment.
Everything all in place after the history rewrite!
Summary
content/api.pyget_course_ids()that returns a flat list of the IDs for every course on the device - then it caches that data against theContentCacheKeyexclude_course_ancestryBooleanFilter field to theContentNodeFilterallowing us to pass a query paramexclude_course_ancestry: trueto exclude all resources that are within a Courseexclude_modalitiesfilter to filter out the Courses themselves in SearchPotential concerns
courses_existfor consistency as this is what it is called in the Coach plugin (just noting I didn't call itcourses_activelike the issue said to).References
Closes #14108
Reviewer guidance
Code
Performance
/profileto see Silk's outputs and do some investigation on how long calls to get recommendations and searches.QA Testing
You will need to have the courses channel imported and to go onto base
release-v0.19.xand interact with some of the courses' descendants (ie, take a couple of the exercises about hummingbirds). This gives your user progress on the course-descended resource so you can see how it's hidden in this PR.After that, checkout this PR and:
document.querySelector('template[data-plugin="kolibri.plugins.learn.app"]').content.textContent.includes("courses_exist")to see that the key is setJSON.parse(document.querySelector('template[data-plugin="kolibri.plugins.learn.app"]').content.textContent).courses_existto see the value of it - when you have Course content it should betrue, otherwise,false.