From c60ea39e5e442705ff1f91687c82300b1cd4311f Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 2 Mar 2026 11:59:00 -0500 Subject: [PATCH 1/2] Hide draft option from PublishSidePanel if user does not have enabled --- .../sidePanels/PublishSidePanel.vue | 19 +++++++++++++++---- .../__tests__/PublishSidePanel.spec.js | 1 + 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue index f16745cd5e..40f815d0b2 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue @@ -13,8 +13,9 @@ @@ -160,6 +162,7 @@ import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; import { LanguagesList } from 'shared/leUtils/Languages'; import logging from 'shared/logging'; + import { FeatureFlagKeys } from 'shared/constants'; export default { name: 'PublishSidePanel', @@ -214,6 +217,7 @@ const currentChannel = computed(() => store.getters['currentChannel/currentChannel']); const getContentNode = computed(() => store.getters['contentNode/getContentNode']); const areAllChangesSaved = computed(() => store.getters['areAllChangesSaved']); + const hasFeatureEnabled = computed(() => store.getters['hasFeatureEnabled']); const incompleteResourcesCount = computed(() => { if (!currentChannel.value) return 0; @@ -252,6 +256,10 @@ return true; }); + const showDraftMode = computed(() => + hasFeatureEnabled.value(FeatureFlagKeys.test_dev_feature), + ); + const submitText = computed(() => { return mode.value === PublishModes.DRAFT ? saveDraft$() : publishAction$(); }); @@ -354,7 +362,9 @@ } if (mode.value === PublishModes.DRAFT) { - await Channel.publishDraft(currentChannel.value.id, { use_staging_tree: false }); + await Channel.publishDraft(currentChannel.value.id, { + use_staging_tree: false, + }); emit('close'); } else { // `newChannelLanguage.value` is a KSelect option { value, label }, so we need to @@ -383,6 +393,7 @@ isChannelLanguageLoading, PublishModes, mode, + showDraftMode, version_notes, submitting, languageOptions, diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/__tests__/PublishSidePanel.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/__tests__/PublishSidePanel.spec.js index 0d95fff1ea..c9baa9ea71 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/__tests__/PublishSidePanel.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/__tests__/PublishSidePanel.spec.js @@ -51,6 +51,7 @@ const renderComponent = (props = {}) => { store.commit('channel/ADD_CHANNEL', currentChannel); store.commit('contentNode/ADD_CONTENTNODE', rootNode); store.commit('SET_UNSAVED_CHANGES', props.areAllChangesSaved === false); + store.commit('UPDATE_SESSION', { is_admin: true }); const router = new VueRouter(); From 597fde69858823d920b7e29bb70fc291db920438 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Mon, 2 Mar 2026 16:32:50 -0500 Subject: [PATCH 2/2] Show draft token in channelEdit view --- .../modals/PreviewDraftChannelModal.vue | 70 +++++++++++++++++++ .../sidePanels/PublishSidePanel.vue | 2 + .../views/TreeView/TreeViewBase.vue | 38 ++++++++++ .../frontend/shared/strings/commonStrings.js | 8 +++ .../strings/communityChannelsStrings.js | 28 ++++++++ .../views/details/StudioDetailsPanel.vue | 28 +++++++- contentcuration/contentcuration/models.py | 6 ++ .../tests/test_channel_version.py | 32 +++++++++ .../tests/viewsets/test_channel.py | 39 +++++++++++ .../contentcuration/viewsets/channel.py | 23 +++++- 10 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/modals/PreviewDraftChannelModal.vue diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/modals/PreviewDraftChannelModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/modals/PreviewDraftChannelModal.vue new file mode 100644 index 0000000000..2229618492 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/modals/PreviewDraftChannelModal.vue @@ -0,0 +1,70 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue index 40f815d0b2..f59a90bf74 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/PublishSidePanel.vue @@ -211,6 +211,7 @@ cancelAction$, languageLabel$, languageRequiredMessage$, + draftBeingPublishedNotice$, versionNotesRequiredMessage$, } = communityChannelsStrings; @@ -365,6 +366,7 @@ await Channel.publishDraft(currentChannel.value.id, { use_staging_tree: false, }); + store.dispatch('showSnackbarSimple', draftBeingPublishedNotice$()); emit('close'); } else { // `newChannelLanguage.value` is a KSelect option { value, label }, so we need to diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue index 4a364278c2..e7739fc4f9 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/TreeView/TreeViewBase.vue @@ -192,6 +192,12 @@ + + {{ getDraftTokenAction$() }} + + import { mapActions, mapGetters, mapState } from 'vuex'; + import PreviewDraftChannelModal from '../../components/modals/PreviewDraftChannelModal.vue'; import Clipboard from '../../components/Clipboard'; import SyncResourcesModal from '../sync/SyncResourcesModal'; import ProgressModal from '../progress/ProgressModal'; @@ -361,6 +373,8 @@ import DraggableRegion from 'shared/views/draggable/DraggableRegion'; import { DropEffect } from 'shared/mixins/draggable/constants'; import DraggablePlaceholder from 'shared/views/draggable/DraggablePlaceholder'; + import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; + import { commonStrings } from 'shared/strings/commonStrings'; export default { name: 'TreeViewBase', @@ -381,8 +395,15 @@ DraggablePlaceholder, SavingIndicator, QuickEditModal, + PreviewDraftChannelModal, }, mixins: [titleMixin], + setup() { + const { getDraftTokenAction$ } = communityChannelsStrings; + return { + getDraftTokenAction$, + }; + }, props: { loading: { type: Boolean, @@ -398,6 +419,7 @@ showSyncModal: false, showClipboard: false, showDeleteModal: false, + showPreviewDraftModal: false, syncing: false, resubmitToCommunityLibraryModalData: null, }; @@ -533,6 +555,22 @@ }, immediate: true, }, + isDraftPublishing(newVal, oldVal) { + if (!newVal && oldVal) { + const { draftPublishedNotice$ } = communityChannelsStrings; + const { previewAction$ } = commonStrings; + const snackbarData = { + text: draftPublishedNotice$(), + }; + if (this.currentChannel.draft_token) { + snackbarData.actionText = previewAction$(); + snackbarData.actionCallback = () => { + this.showPreviewDraftModal = true; + }; + } + this.$store.dispatch('showSnackbar', snackbarData); + } + }, }, methods: { ...mapActions('channel', ['deleteChannel']), diff --git a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js index 14f9d7518a..f0bdc3dd10 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js @@ -18,4 +18,12 @@ export const commonStrings = createTranslator('CommonStrings', { message: 'Sorry! Something went wrong, please try again.', context: 'Default error message for operation errors.', }, + previewAction: { + message: 'Preview', + context: 'A label for an action that opens a preview of content', + }, + dismissAction: { + message: 'Dismiss', + context: 'A label for an action that dismisses a notification or message', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js index f7c0bd578c..79cb1ea62f 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js @@ -381,4 +381,32 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin context: 'Notice for screen readers on the new notifications badge to indicate that new notifications have arrived', }, + + // Draft channel strings + draftBeingPublishedNotice: { + message: 'Draft version is being published', + context: 'Label indicating that a draft version of the channel is currently being published', + }, + draftPublishedNotice: { + message: 'Draft published successfully', + context: 'Label indicating that a draft version of the channel has been successfully published', + }, + previewYourDraftTitle: { + message: 'Preview your draft channel in Kolibri', + context: 'Title for the modal that shows instructions to preview a draft channel in Kolibri', + }, + channelTokenDescription: { + message: + 'To preview your draft channel right away, simply copy the unique draft channel token. This is the sole method to access the channel.', + context: 'Description for the channel token field in the draft preview instructions modal', + }, + getDraftTokenAction: { + message: 'Get draft token', + context: + 'Button text for the action to retrieve the draft token in the draft preview instructions modal', + }, + draftTokenLabel: { + message: 'Draft token', + context: 'Label for the draft token field in the draft preview instructions modal', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue index e8542cad76..898fdef99c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue +++ b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue @@ -43,7 +43,23 @@ :showLabel="false" /> - {{ _details.primary_token.slice(0, 5) + '-' + _details.primary_token.slice(5) }} + {{ hyphenateToken(_details.primary_token) }} + + + + + @@ -390,6 +406,8 @@ import StudioDetailsRow from './StudioDetailsRow'; import StudioThumbnail from 'shared/views/files/StudioThumbnail'; import StudioCopyToken from 'shared/views/StudioCopyToken'; + import useToken from 'shared/composables/useToken'; + import { communityChannelsStrings } from 'shared/strings/communityChannelsStrings'; const DEFAULT_DETAILS = { name: '', @@ -442,6 +460,14 @@ titleMixin, metadataTranslationMixin, ], + setup() { + const { hyphenateToken } = useToken(); + const { draftTokenLabel$ } = communityChannelsStrings; + return { + hyphenateToken, + draftTokenLabel$, + }; + }, props: { // Object matching that returned by the channel details and // node details API endpoints, see backend for details of the diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 4dd308dd12..3f66d14fba 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -1388,6 +1388,12 @@ def get_resource_count(self): def get_human_token(self): return self.secret_tokens.get(is_primary=True) + def get_draft_token(self): + draft_version = self.channel_versions.filter(version=None).first() + if not draft_version: + return None + return draft_version.secret_token + def get_channel_id_token(self): return self.secret_tokens.get(token=self.id) diff --git a/contentcuration/contentcuration/tests/test_channel_version.py b/contentcuration/contentcuration/tests/test_channel_version.py index bc69c9c243..7dd0eedfa8 100644 --- a/contentcuration/contentcuration/tests/test_channel_version.py +++ b/contentcuration/contentcuration/tests/test_channel_version.py @@ -70,3 +70,35 @@ def test_version_cannot_exceed_channel_version(self): ) with self.assertRaises(ValidationError): cv.save() + + def test_get_draft_token_returns_token_when_draft_version_exists(self): + """Test get_draft_token returns the secret_token of the version=None ChannelVersion.""" + draft_version = ChannelVersion.objects.create( + channel=self.channel, + version=None, + ) + token = draft_version.new_token() + + result = self.channel.get_draft_token() + self.assertEqual(result, token) + self.assertIsInstance(result, SecretToken) + self.assertFalse(result.is_primary) + + def test_get_draft_token_returns_none_when_no_draft_version(self): + """Test get_draft_token returns None when no version=None ChannelVersion exists.""" + # Only create versioned ChannelVersions (not drafts) + ChannelVersion.objects.create( + channel=self.channel, + version=1, + ) + result = self.channel.get_draft_token() + self.assertIsNone(result) + + def test_get_draft_token_returns_none_when_draft_has_no_token(self): + """Test get_draft_token returns None when a draft version exists but has no token.""" + ChannelVersion.objects.create( + channel=self.channel, + version=None, + ) + result = self.channel.get_draft_token() + self.assertIsNone(result) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 9a3a76c2e2..56a460d415 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -975,6 +975,45 @@ def test_admin_restore_channel(self): channel.history.filter(actor=user, action=channel_history.RECOVERY).count(), ) + def test_channel_detail_includes_draft_token_when_draft_version_exists(self): + """Test that the channel API response includes draft_token when a draft ChannelVersion exists.""" + user = testdata.user() + channel = models.Channel.objects.create( + actor_id=user.id, **self.channel_metadata + ) + channel.editors.add(user) + + # Create a draft ChannelVersion (version=None) with a token + draft_version = ChannelVersion.objects.create( + channel=channel, + version=None, + ) + token = draft_version.new_token() + + self.client.force_authenticate(user=user) + response = self.client.get( + reverse("channel-detail", kwargs={"pk": channel.id}), + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.data["draft_token"], token.token) + + def test_channel_detail_draft_token_is_none_when_no_draft_version(self): + """Test that the channel API response has draft_token=None when no draft version exists.""" + user = testdata.user() + channel = models.Channel.objects.create( + actor_id=user.id, **self.channel_metadata + ) + channel.editors.add(user) + + self.client.force_authenticate(user=user) + response = self.client.get( + reverse("channel-detail", kwargs={"pk": channel.id}), + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertIsNone(response.data["draft_token"]) + class UnpublishedChangesQueryTestCase(StudioAPITestCase): def test_unpublished_changes_query_with_channel_object(self): diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 82ff6e0c34..2e1d54c4d8 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -453,7 +453,12 @@ class ChannelViewSet(ValuesViewset): ordering = "-modified" field_map = channel_field_map - values = base_channel_values + ("edit", "view", "unpublished_changes") + values = base_channel_values + ( + "edit", + "view", + "unpublished_changes", + "draft_token", + ) def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) @@ -509,6 +514,14 @@ def get_queryset(self): view=Exists(user_queryset.filter(view_only_channels=OuterRef("id"))), ) + def _annotate_draft_token(self, queryset): + draft_token_subquery = Subquery( + ChannelVersion.objects.filter(channel=OuterRef("id"), version=None).values( + "secret_token__token" + )[:1] + ) + return queryset.annotate(draft_token=draft_token_subquery) + def annotate_queryset(self, queryset): queryset = queryset.annotate(primary_token=primary_token_subquery) channel_main_tree_nodes = ContentNode.objects.filter( @@ -530,6 +543,8 @@ def annotate_queryset(self, queryset): unpublished_changes=Exists(_unpublished_changes_query(OuterRef("id"))) ) + queryset = self._annotate_draft_token(queryset) + return queryset def publish_from_changes(self, changes): @@ -582,6 +597,7 @@ def publish(self, pk, version_notes="", language=None): "publishing": False, "version": channel.version, "primary_token": channel.get_human_token().token, + "draft_token": None, "last_published": channel.last_published, "unpublished_changes": _unpublished_changes_query( channel @@ -656,13 +672,16 @@ def publish_next(self, pk, use_staging_tree=False): is_draft_version=True, use_staging_tree=use_staging_tree, ) + draft_token = channel.get_draft_token() Change.create_changes( [ generate_update_event( channel.id, CHANNEL, { - "primary_token": channel.get_human_token().token, + "draft_token": draft_token.token + if draft_token + else None, }, channel_id=channel.id, ),