Skip to content

Commit f9d039f

Browse files
claudertibbles
authored andcommitted
Add channel token support to importchannel management command
Implements support for using channel tokens in addition to channel IDs when importing channels via the importchannel command, addressing issue #3733. Key features: - Token resolution via channel lookup API (works with Studio or any Kolibri instance) - Automatic detection of tokens vs channel IDs using UUID validation - Interactive multi-channel selection when token resolves to multiple channels - Non-interactive mode shows error with all channel options - Comprehensive error handling with user-friendly messages - Full backward compatibility with existing channel ID usage Implementation details: - Added resolve_channel_token() utility in paths.py using duck typing - Returns tuple of (channel_id, all_channels) for flexible handling - Command detects TTY to determine interactive vs non-interactive mode - Interactive mode prompts user to choose from numbered list - Non-interactive mode fails with clear error listing all options - Only network subcommand supports tokens (disk requires UUIDs) Error handling improvements: - JSON parse errors caught with helpful messages - Network failures, invalid tokens, malformed responses all handled - Validates all channels have IDs before returning - Clear error messages guide users to resolution Test coverage: - Token resolution (single and multiple channels) - Invalid JSON responses - Missing channel IDs - Default and custom baseurl - All error paths covered Fixes #3733
1 parent eb69170 commit f9d039f

4 files changed

Lines changed: 419 additions & 4 deletions

File tree

kolibri/core/content/management/commands/importchannel.py

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@
66
from kolibri.core.content.constants.transfer_types import COPY_METHOD
77
from kolibri.core.content.constants.transfer_types import DOWNLOAD_METHOD
88
from kolibri.core.content.utils.channel_transfer import transfer_channel
9+
from kolibri.core.content.utils.paths import resolve_channel_token
10+
from kolibri.core.discovery.utils.network.errors import NetworkLocationConnectionFailure
11+
from kolibri.core.discovery.utils.network.errors import NetworkLocationNotFound
12+
from kolibri.core.discovery.utils.network.errors import NetworkLocationResponseFailure
913
from kolibri.core.tasks.management.commands.base import AsyncCommand
1014
from kolibri.utils import conf
15+
from kolibri.utils.uuids import is_valid_uuid
1116

1217
logger = logging.getLogger(__name__)
1318

@@ -29,7 +34,8 @@ def add_arguments(self, parser):
2934
network_subparser.add_argument(
3035
"channel_id",
3136
type=str,
32-
help="Download the database for the given channel_id.",
37+
help="Download the database for the given channel_id or channel token. "
38+
"Tokens are resolved by querying the content server (e.g., Kolibri Studio).",
3339
)
3440

3541
default_studio_url = conf.OPTIONS["Urls"]["CENTRAL_CONTENT_BASE_URL"]
@@ -59,7 +65,8 @@ def add_arguments(self, parser):
5965
local_subparser.add_argument(
6066
"channel_id",
6167
type=str,
62-
help="Import this channel id from the given directory.",
68+
help="Import this channel id from the given directory. "
69+
"Note: Only channel IDs (UUIDs) are accepted, not tokens.",
6370
)
6471
local_subparser.add_argument(
6572
"directory", type=str, help="Import content from this directory."
@@ -76,17 +83,103 @@ def add_arguments(self, parser):
7683
help="Download the database to the given content dir.",
7784
)
7885

86+
def _resolve_channel_identifier(self, identifier, baseurl):
87+
"""
88+
Resolve a channel identifier (channel_id or token) to a channel_id.
89+
90+
:param identifier: Either a channel UUID or a channel token
91+
:param baseurl: The base URL of the content server
92+
:return: The resolved channel_id
93+
:raises: CommandError if token resolution fails
94+
"""
95+
# Check if the identifier is already a valid UUID (channel_id)
96+
if is_valid_uuid(identifier):
97+
logger.info("Using channel ID: {}".format(identifier))
98+
return identifier
99+
100+
# Otherwise, treat it as a token and try to resolve it
101+
logger.info("Resolving channel token '{}'...".format(identifier))
102+
try:
103+
channel_id, all_channels = resolve_channel_token(
104+
identifier, baseurl=baseurl
105+
)
106+
107+
# Handle case where multiple channels are returned
108+
if len(all_channels) > 1:
109+
# Show error with all options
110+
logger.warning(
111+
"Multiple channels found for token '{}':".format(identifier)
112+
)
113+
for channel in all_channels:
114+
logger.warning(
115+
"{} (ID: {})\n".format(
116+
channel.get("name", "Unnamed Channel"), channel["id"]
117+
)
118+
)
119+
raise CommandError(
120+
(
121+
"Token '{}' resolved to multiple channels. "
122+
"Please use a specific channel ID instead:\n".format(identifier)
123+
)
124+
)
125+
126+
logger.info(
127+
"Successfully resolved token '{}' to channel ID: {}".format(
128+
identifier, channel_id
129+
)
130+
)
131+
return channel_id
132+
except NetworkLocationConnectionFailure:
133+
raise CommandError(
134+
"Failed to connect to content server at '{}'. "
135+
"Please check your network connection and try again.".format(
136+
baseurl or "Kolibri Studio"
137+
)
138+
)
139+
except NetworkLocationNotFound:
140+
raise CommandError(
141+
"Content server not found at '{}'. "
142+
"Please check the URL and try again.".format(
143+
baseurl or "Kolibri Studio"
144+
)
145+
)
146+
except NetworkLocationResponseFailure as e:
147+
raise CommandError(
148+
"Token '{}' not found on content server. "
149+
"Please verify the token is correct. Error: {}".format(identifier, e)
150+
)
151+
except ValueError as e:
152+
raise CommandError("Invalid token or channel ID: {}".format(e))
153+
except Exception as e:
154+
logger.error(
155+
"Unexpected error resolving token '{}': {}".format(identifier, e)
156+
)
157+
raise CommandError(
158+
"Failed to resolve token '{}'. Error: {}".format(identifier, e)
159+
)
160+
79161
def download_channel(self, channel_id, baseurl, no_upgrade, content_dir):
80-
logger.info("Downloading data for channel id {}".format(channel_id))
162+
# Resolve the identifier (could be channel_id or token)
163+
resolved_channel_id = self._resolve_channel_identifier(channel_id, baseurl)
164+
165+
logger.info("Downloading data for channel id {}".format(resolved_channel_id))
81166
transfer_channel(
82-
channel_id=channel_id,
167+
channel_id=resolved_channel_id,
83168
method=DOWNLOAD_METHOD,
84169
no_upgrade=no_upgrade,
85170
content_dir=content_dir,
86171
baseurl=baseurl,
87172
)
88173

89174
def copy_channel(self, channel_id, source_path, no_upgrade, content_dir):
175+
# For disk imports, only accept valid UUIDs (not tokens)
176+
if not is_valid_uuid(channel_id):
177+
raise CommandError(
178+
"Invalid channel ID: '{}'. The 'disk' subcommand only accepts "
179+
"channel IDs (UUIDs), not tokens. Tokens are only supported for "
180+
"network imports using the 'network' subcommand.".format(channel_id)
181+
)
182+
90183
logger.info("Copying in data for channel id {}".format(channel_id))
91184
transfer_channel(
92185
channel_id=channel_id,

kolibri/core/content/test/test_import_export.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,123 @@ def test_remote_successful_import_clears_stats_cache(
672672
call_command("importchannel", "network", self.the_channel_id)
673673
self.assertTrue(channel_stats_clear_mock.called)
674674

675+
@patch(
676+
"kolibri.core.content.utils.channel_transfer.paths.get_content_database_file_url"
677+
)
678+
@patch(
679+
"kolibri.core.content.utils.channel_transfer.paths.get_content_database_file_path"
680+
)
681+
@patch("kolibri.core.content.utils.channel_transfer.transfer.FileDownload")
682+
@patch("kolibri.core.content.utils.channel_transfer.get_current_job")
683+
@patch("kolibri.core.discovery.utils.network.client.NetworkClient")
684+
def test_network_import_with_token(
685+
self,
686+
mock_network_client_class,
687+
get_current_job_mock,
688+
FileDownloadMock,
689+
local_path_mock,
690+
remote_path_mock,
691+
start_progress_mock,
692+
import_channel_mock,
693+
):
694+
"""Test that network import resolves tokens to channel IDs"""
695+
# Setup mock for token resolution
696+
mock_client = MagicMock()
697+
mock_response = MagicMock()
698+
mock_response.json.return_value = [
699+
{
700+
"id": self.the_channel_id,
701+
"name": "Test Channel",
702+
}
703+
]
704+
mock_client.get.return_value = mock_response
705+
mock_network_client_class.build_for_address.return_value = mock_client
706+
707+
# Setup mock for the actual import
708+
dummy_job = create_dummy_job()
709+
get_current_job_mock.return_value = dummy_job
710+
fd, local_path = tempfile.mkstemp()
711+
os.close(fd)
712+
local_path_mock.return_value = local_path
713+
remote_path_mock.return_value = "notest"
714+
import_channel_mock.return_value = True
715+
716+
# Call with a token instead of channel ID
717+
call_command("importchannel", "network", "test-token")
718+
719+
# Verify token was resolved (NetworkClient was called)
720+
mock_network_client_class.build_for_address.assert_called()
721+
# Verify the import proceeded
722+
import_channel_mock.assert_called_once()
723+
724+
@patch("kolibri.core.discovery.utils.network.client.NetworkClient")
725+
def test_network_import_token_not_found(
726+
self,
727+
mock_network_client_class,
728+
start_progress_mock,
729+
import_channel_mock,
730+
):
731+
"""Test that network import fails gracefully when token is not found"""
732+
# Setup mock for token resolution failure
733+
mock_client = MagicMock()
734+
mock_response = MagicMock()
735+
mock_response.json.return_value = [] # Empty list = token not found
736+
mock_client.get.return_value = mock_response
737+
mock_network_client_class.build_for_address.return_value = mock_client
738+
739+
# Call with an invalid token - should raise CommandError
740+
with self.assertRaises(CommandError) as context:
741+
call_command("importchannel", "network", "invalid-token")
742+
743+
self.assertIn("not found", str(context.exception).lower())
744+
745+
@patch("kolibri.core.discovery.utils.network.client.NetworkClient")
746+
@patch("sys.stdin")
747+
@patch("sys.stdout")
748+
def test_network_import_token_multiple_channels_non_interactive(
749+
self,
750+
mock_stdout,
751+
mock_stdin,
752+
mock_network_client_class,
753+
start_progress_mock,
754+
import_channel_mock,
755+
):
756+
"""Test that non-interactive mode errors when token resolves to multiple channels"""
757+
# Setup mock for multiple channel response
758+
mock_client = MagicMock()
759+
mock_response = MagicMock()
760+
mock_response.json.return_value = [
761+
{"id": "aa480b60a7f4526f886e7df9f4e9b8ca", "name": "Channel A"},
762+
{"id": "bb480b60a7f4526f886e7df9f4e9b8cb", "name": "Channel B"},
763+
]
764+
mock_client.get.return_value = mock_response
765+
mock_network_client_class.build_for_address.return_value = mock_client
766+
767+
# Make stdin/stdout non-interactive
768+
mock_stdin.isatty.return_value = False
769+
mock_stdout.isatty.return_value = False
770+
771+
# Should raise CommandError with info about both channels
772+
with self.assertRaises(CommandError) as context:
773+
call_command("importchannel", "network", "multi-token")
774+
775+
error_msg = str(context.exception)
776+
self.assertIn("multiple channels", error_msg.lower())
777+
778+
def test_disk_import_rejects_token(
779+
self,
780+
start_progress_mock,
781+
import_channel_mock,
782+
):
783+
"""Test that disk import rejects tokens and only accepts UUIDs"""
784+
# Call with a token instead of UUID - should raise CommandError
785+
with self.assertRaises(CommandError) as context:
786+
call_command("importchannel", "disk", "test-token", tempfile.mkdtemp())
787+
788+
error_msg = str(context.exception)
789+
self.assertIn("invalid channel id", error_msg.lower())
790+
self.assertIn("disk", error_msg.lower())
791+
675792

676793
@patch(
677794
"kolibri.core.content.utils.resource_import.lookup_channel_listing_status",

0 commit comments

Comments
 (0)