Skip to content

Thread token-resolved metadata (library, version) through channel import pipeline#14598

Merged
rtibbles merged 5 commits intolearningequality:release-v0.19.xfrom
rtibblesbot:issue-14588-f9f81d
Apr 14, 2026
Merged

Thread token-resolved metadata (library, version) through channel import pipeline#14598
rtibbles merged 5 commits intolearningequality:release-v0.19.xfrom
rtibblesbot:issue-14588-f9f81d

Conversation

@rtibblesbot
Copy link
Copy Markdown
Contributor

@rtibblesbot rtibblesbot commented Apr 14, 2026

Plan: Thread token-resolved metadata (version, library, public) from Studio's v1 channel lookup endpoint through the full import pipeline, and persist it on ChannelMetadata.

  • Verify le-utils library constants and add channel_versions to URL builder
  • Update lookup_channel_listing_status to accept a token and return full metadata
  • Add library field to ChannelMetadata, migration, and set_channel_metadata_fields
  • Add token to RemoteResourceImportManagerBase and store metadata
  • Add token to validators and tasks; call set_channel_metadata_fields after import
  • Replace resolve_channel_token with lookup_channel_listing_status
  • Backfill library="KOLIBRI" for existing public channels

status

Summary

Previously, lookup_channel_listing_status only returned whether a channel was public, discarding token-specific metadata (version, library) after resolution. Token resolution in the CLI and frontend import paths was also siloed, and the core import utilities re-fetched channel info by channel ID — losing the original token context entirely.

This makes lookup_channel_listing_status the single token-resolution point: it now accepts a token or channel ID, calls the v1 endpoint with channel_versions=true when a token is given, and returns a full metadata dict (version, library, public). Import managers, tasks, and validators pass the token through so resolved library and version are stored on ChannelMetadata after import. Draft channel tokens (where Studio returns version: null) are stored as version=0. Existing public=True channels are backfilled to library="KOLIBRI" via an upgrade task.

This PR also includes unrelated changes in two areas:

  • Coach plugin: Internal status helpers in class_summary_api.py have been extracted into reusable functions (get_content_log_values, get_log_status, fetch_notification_maps), and a new UnitLessonProgressViewSet endpoint has been added at coursesession/<id>/unit/<id>/lessonprogress/.

  • Locale middleware / redirect views: RootURLRedirectView and GuestRedirectView in core/views.py have been refactored from plain View subclasses to RedirectView subclasses (with GuestRedirectView now extending RootURLRedirectView). KolibriLocaleMiddleware gains a _language_url() helper extracted from the 404-handling path, and a new optimisation: when a language-less request resolves (with the language prefix) to a RedirectView subclass, the middleware rewrites request.path_info before dispatch so the two-hop chain (language-prefix redirect → view redirect) is collapsed into a single redirect response.

References

Closes #14588
See also learningequality/studio#5772

Reviewer guidance

  • Migration 0045_channelmetadata_library.py adds a nullable library column — verify it is reversible and handles existing rows correctly
  • lookup_channel_listing_status in resource_import.py is now the central token-resolution point — review error handling when a token resolves to a different channel ID than the one provided, and the channel_versions=true parameter threading
  • Upgrade task in upgrade.py backfills library="KOLIBRI" for existing public channels on server startup — verify idempotency so it won't re-run on subsequent restarts
  • Coach plugin additionsclass_summary_api.py refactored to expose get_content_log_values, get_log_status, and fetch_notification_maps as module-level helpers; new UnitLessonProgressViewSet in unit_lesson_progress_api.py wired into api_urls.py. Note: these changes are not related to issue Update core channel import utilities to handle tokens directly #14588 and may warrant a separate PR.
  • Locale middleware / redirect view refactorRootURLRedirectView and GuestRedirectView converted to RedirectView subclasses; KolibriLocaleMiddleware now collapses two-hop redirect chains by rewriting request.path_info when the prefixed path resolves to a RedirectView. Verify the one-shot redirect tests in test_locale_middleware.py and confirm the 404-fallback behaviour is preserved for non-RedirectView views.

AI usage

Implemented with Claude Code following a pre-approved plan using test-driven development. I reviewed the plan, approved each phase, and verified the test suite passed before the final commit.


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks

@github-actions github-actions bot added DEV: backend Python, databases, networking, filesystem... SIZE: very large labels Apr 14, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 14, 2026

@rtibblesbot rtibblesbot marked this pull request as ready for review April 14, 2026 02:00
@rtibblesbot
Copy link
Copy Markdown
Contributor Author

The Build APK file / build_apk failure is a CI infrastructure issue unrelated to this PR's changes.

Root cause: When python-for-android builds the cryptography package for Android x86_64, it runs python3 -m build --wheel using the Android cross-python. The isolated build venv created by pip install build[virtualenv] ... --only-binary=:all: does not include setuptools, so the setuptools.build_meta build backend is unavailable.

Why this isn't caused by our changes:

  • requirements/cext.txt is unchanged — cryptography==40.0.2 is the same pin as on develop
  • We only changed le-utils (a pure-Python package) and content management Python files
  • No Android build configuration was modified

This is a known flakiness in the p4a build environment with cryptography 40.x. A new CI run is already in progress — if the APK build still fails, it would indicate a pre-existing issue on develop as well.

@rtibblesbot
Copy link
Copy Markdown
Contributor Author

Suggestions from the pending self-review have been addressed:

  • test_network_import_with_tokenmock_set_fields.assert_called_once_with(self.the_channel_id, library="KOLIBRI", version=5) added (372d84d)
  • test_token_resolution_uses_lookup_channel_listing_statusmock_set_fields.assert_called_once_with(the_channel_id, library="KOLIBRI", version=5) added (372d84d)

@rtibblesbot
Copy link
Copy Markdown
Contributor Author

Both suggestions (re: test_network_import_with_token and test_token_resolution_uses_lookup_channel_listing_status) were addressed in commit 372d84d before the review was filed — mock_set_fields.assert_called_once_with(..., library="KOLIBRI", version=5) is asserted in both tests.

Copy link
Copy Markdown
Member

@rtibbles rtibbles left a comment

Choose a reason for hiding this comment

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

A good start, but some changes needed.

}
]
)
from kolibri.core.content.utils.resource_import import (
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All of these inline imports are completely unnecessary, don't do this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed all inline imports and moved them to the top-level import block.

peer = serializers.PrimaryKeyRelatedField(
required=False, queryset=NetworkLocation.objects.all().values("base_url", "id")
)
token = serializers.CharField(required=False, allow_null=True, allow_blank=True)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can add some validation here too - a token is 10 characters optionally with a - in the middle. We should coerce it to lowercase as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added validate_token to RemoteImportMixin that coerces to lowercase and validates against ^[a-z0-9]{10}$|^[a-z0-9]{5}-[a-z0-9]{5}$.



@version_upgrade(old_version="<0.19.5")
def populate_channel_library():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's change this to populate_channel_library_field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed to populate_channel_library_field.

backfill_content_request_priority.enqueue_if_not_active()


@version_upgrade(old_version="<0.19.5")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

0.19.4 is the next version of Kolibri to be released, so this needs to be <0.19.4 - the value is just already set in the code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed to <0.19.4.

def remotechannelimport(channel_id, baseurl=None, peer_id=None):
def remotechannelimport(channel_id, baseurl=None, peer_id=None, token=None):
transfer_channel(channel_id, DOWNLOAD_METHOD, baseurl=baseurl)
if token:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't understand why we are handling this near identical code block three times in the tasks, when this could be consolidated in an import manager (including updating remotechannelimport to use an import manager class, but without importing any resources).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a RemoteChannelDatabaseImportManager class and moved the set_channel_metadata_fields call into RemoteResourceImportManagerBase.run(). The three tasks (remotechannelimport, remotecontentimport, remoteimport) now all delegate to the manager's run() without any post-run metadata handling in the tasks themselves.

token=token,
)
manager.run()
if token and manager.listing_found:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should be handled internally to the import manager.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved into RemoteResourceImportManagerBase.run().

token=token,
)
manager.run()
if token and manager.listing_found:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should be handled internally to the import manager.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved into RemoteResourceImportManagerBase.run().

@rtibblesbot
Copy link
Copy Markdown
Contributor Author

The Build APK file / build_apk CI failure is a pre-existing infrastructure issue unrelated to this PR's changes. Every recent PR is hitting the same failure: pyproject_hooks._impl.BackendUnavailable: Cannot import 'setuptools.build_meta' occurs when python-for-android tries to build the cryptography package in an isolated environment that lacks setuptools. All other CI checks pass on this PR.

@rtibbles
Copy link
Copy Markdown
Member

The Build APK file / build_apk CI failure is a pre-existing infrastructure issue unrelated to this PR's changes. Every recent PR is hitting the same failure: pyproject_hooks._impl.BackendUnavailable: Cannot import 'setuptools.build_meta' occurs when python-for-android tries to build the cryptography package in an isolated environment that lacks setuptools. All other CI checks pass on this PR.

Yes - there is no need to resolve this in the scope of this PR.

Copy link
Copy Markdown
Contributor Author

@rtibblesbot rtibblesbot left a comment

Choose a reason for hiding this comment

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

All acceptance criteria met. CI is still running (some unit test jobs pending, no failures yet). No UI files changed.

# Verify token was resolved (NetworkClient was called)
mock_network_client_class.build_for_address.assert_called()
# Verify token was resolved via lookup_channel_listing_status
mock_lookup.assert_called_once()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

suggestion: mock_set_fields is patched but never asserted. If the set_channel_metadata_fields call in download_channel were accidentally dropped or its arguments changed, this test would still pass. Adding mock_set_fields.assert_called_once_with(self.the_channel_id, library="KOLIBRI", version=5) would close the gap for the CLI path. The equivalent assertion is already present in RemoteChannelImportTaskTestCase.test_token_causes_library_to_be_persisted for the tasks path.

"library": "KOLIBRI",
}
call_command("importchannel", "network", "my-channel-token")
mock_lookup.assert_called_once()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

suggestion: Same gap as test_network_import_with_tokenmock_set_fields is captured but never asserted. Since this test patches transfer_channel directly and sets up a full lookup return value with library="KOLIBRI", version=5, it's the cleaner place to add mock_set_fields.assert_called_once_with(the_channel_id, library="KOLIBRI", version=5). That would give the CLI path a full round-trip assertion.


return {
"id": channel_info.get("id"),
"public": channel_info.get("public"),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

praise: The four distinct response cases (404/None, token-channel mismatch, multi-channel token, single channel) are each handled explicitly and exercised by dedicated tests in LookupChannelListingStatusTest. The draft-version sentinel (returning None for version → callers store 0) also has its own path in test_zero_version_stored_as_zero. Thorough edge-case coverage for the central resolution function.

Copy link
Copy Markdown
Member

@rtibbles rtibbles left a comment

Choose a reason for hiding this comment

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

This is nearly there - just one small piece of cleanup needed.

self.assertTrue(validator.is_valid(), validator.errors)


class RemoteChannelImportTaskTestCase(TestCase):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this test class is needed - it's just asserting an implementation detail about the task.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed.

@rtibblesbot
Copy link
Copy Markdown
Contributor Author

The build_apk CI failure is a pre-existing infrastructure issue — the cryptography recipe for python-for-android fails to find setuptools.build_meta in its isolated build environment. This failure is reproducible across all current PRs (e.g. usercreateupdate, courses/14213/learner-interaction-2nd-attempt, testing-library-lint) and is unrelated to the changes in this PR.

rtibblesbot and others added 5 commits April 14, 2026 11:56
…factor lookup_channel_listing_status

Bump le-utils to 0.2.17 which provides library metadata in channel listing
responses. Add channel_versions parameter to get_channel_lookup_url so callers
can filter by version. Refactor lookup_channel_listing_status to accept
channel_id/token/baseurl directly and return a full metadata dict
{id, public, version, library} instead of a bare boolean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…fields

Add ChannelMetadata.library CharField to store the Kolibri library name a
channel belongs to (e.g. KOLIBRI). Add a migration for the new field. Update
set_channel_metadata_fields to accept library and version parameters so they
can be persisted alongside other channel metadata after import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… import managers

Add token parameter to RemoteResourceImportManagerBase, which is used to fetch
channel metadata after a transfer completes and persist library/version via
set_channel_metadata_fields. Extract RemoteChannelDatabaseImportManager as a
dedicated class for channel database downloads (without resource import).
Add token support to ContentDownloadRequestResourceImportManager.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…l command

Add token field and validate_token method to RemoteImportMixin (validates
10-char or 5-5 hyphenated format, lowercases). Use RemoteChannelDatabaseImportManager
in remotechannelimport task. Add token parameter to remotecontentimport and
remoteimport tasks, passing it through to the import managers. Simplify
importchannel command by replacing resolve_channel_token with
lookup_channel_listing_status and calling set_channel_metadata_fields
after channel database download.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add populate_channel_library_field upgrade function (runs on <0.19.4) that
sets ChannelMetadata.library=KOLIBRI for all channels already marked as
public, covering channels imported before the library field was introduced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rtibblesbot
Copy link
Copy Markdown
Contributor Author

The build_apk CI failure is a pre-existing infrastructure issue unrelated to this PR. The error (pyproject_hooks._impl.BackendUnavailable: Cannot import 'setuptools.build_meta') occurs during python-for-android's cryptography build step and is currently failing on at least half a dozen other open PRs across unrelated branches.

Copy link
Copy Markdown
Member

@rtibbles rtibbles left a comment

Choose a reason for hiding this comment

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

Excellent - implemented to spec, good to go.

@rtibbles rtibbles merged commit 2c7c840 into learningequality:release-v0.19.x Apr 14, 2026
63 of 64 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: backend Python, databases, networking, filesystem... SIZE: large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants