diff --git a/.github/workflows/deploytest.yml b/.github/workflows/deploytest.yml index d63794aaeb..8935b4f4c7 100644 --- a/.github/workflows/deploytest.yml +++ b/.github/workflows/deploytest.yml @@ -27,7 +27,7 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v3 with: @@ -51,7 +51,7 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.9 uses: actions/setup-python@v4 with: diff --git a/.github/workflows/frontendlint.yml b/.github/workflows/frontendlint.yml index d0a3e648f2..03bcc2f474 100644 --- a/.github/workflows/frontendlint.yml +++ b/.github/workflows/frontendlint.yml @@ -27,7 +27,7 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v3 with: diff --git a/.github/workflows/frontendtest.yml b/.github/workflows/frontendtest.yml index 7489c2748a..fe5a2968a9 100644 --- a/.github/workflows/frontendtest.yml +++ b/.github/workflows/frontendtest.yml @@ -27,7 +27,7 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v3 with: diff --git a/.github/workflows/pythontest.yml b/.github/workflows/pythontest.yml index f015f7bc1e..5c7ca726ec 100644 --- a/.github/workflows/pythontest.yml +++ b/.github/workflows/pythontest.yml @@ -61,7 +61,7 @@ jobs: # Maps port 6379 on service container to the host - 6379:6379 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up minio run: | docker run -d -p 9000:9000 --name minio \ diff --git a/Makefile b/Makefile index b7575fe74e..be4a0b7b51 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ migrate: # 4) Remove the management command from this `deploy-migrate` recipe # 5) Repeat! deploy-migrate: - python contentcuration/manage.py export_channels_to_kolibri_public + echo "Nothing to do here!" contentnodegc: python contentcuration/manage.py garbage_collect diff --git a/contentcuration/automation/__init__.py b/contentcuration/automation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contentcuration/automation/admin.py b/contentcuration/automation/admin.py new file mode 100644 index 0000000000..4185d360e9 --- /dev/null +++ b/contentcuration/automation/admin.py @@ -0,0 +1,3 @@ +# from django.contrib import admin + +# Register your models here. diff --git a/contentcuration/automation/apps.py b/contentcuration/automation/apps.py new file mode 100644 index 0000000000..eaa1d3d4e1 --- /dev/null +++ b/contentcuration/automation/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AutomationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'automation' diff --git a/contentcuration/automation/migrations/__init__.py b/contentcuration/automation/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contentcuration/automation/models.py b/contentcuration/automation/models.py new file mode 100644 index 0000000000..0b4331b362 --- /dev/null +++ b/contentcuration/automation/models.py @@ -0,0 +1,3 @@ +# from django.db import models + +# Create your models here. diff --git a/contentcuration/automation/tests.py b/contentcuration/automation/tests.py new file mode 100644 index 0000000000..a79ca8be56 --- /dev/null +++ b/contentcuration/automation/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/contentcuration/automation/views.py b/contentcuration/automation/views.py new file mode 100644 index 0000000000..fd0e044955 --- /dev/null +++ b/contentcuration/automation/views.py @@ -0,0 +1,3 @@ +# from django.shortcuts import render + +# Create your views here. diff --git a/contentcuration/contentcuration/db/models/manager.py b/contentcuration/contentcuration/db/models/manager.py index 4d833caf8f..db1e3a77bf 100644 --- a/contentcuration/contentcuration/db/models/manager.py +++ b/contentcuration/contentcuration/db/models/manager.py @@ -487,6 +487,7 @@ def _copy_tags(self, source_copy_id_map): tag_id_map[tag.id] = new_tag.id tags_to_create.append(new_tag) + # TODO: Can cleanup the above and change the below to use ignore_conflicts=True ContentTag.objects.bulk_create(tags_to_create) mappings_to_create = [ @@ -499,7 +500,10 @@ def _copy_tags(self, source_copy_id_map): for mapping in node_tags_mappings ] - self.model.tags.through.objects.bulk_create(mappings_to_create) + # In the case that we are copying a node that is in the weird state of having a tag + # that is duplicated (with a channel tag and a null channel tag) this can cause an error + # so we ignore conflicts here to ignore the duplicate tags. + self.model.tags.through.objects.bulk_create(mappings_to_create, ignore_conflicts=True) def _copy_assessment_items(self, source_copy_id_map): from contentcuration.models import File diff --git a/contentcuration/contentcuration/dev_settings.py b/contentcuration/contentcuration/dev_settings.py index d81d23a993..439bdef8af 100644 --- a/contentcuration/contentcuration/dev_settings.py +++ b/contentcuration/contentcuration/dev_settings.py @@ -5,4 +5,4 @@ ROOT_URLCONF = "contentcuration.dev_urls" -INSTALLED_APPS += ("drf_yasg",) +INSTALLED_APPS += ("drf_yasg", "automation") diff --git a/contentcuration/contentcuration/frontend/administration/mixins.js b/contentcuration/contentcuration/frontend/administration/mixins.js index 262dbf3cc9..c27e933ed5 100644 --- a/contentcuration/contentcuration/frontend/administration/mixins.js +++ b/contentcuration/contentcuration/frontend/administration/mixins.js @@ -160,7 +160,7 @@ export const tableMixin = { ...this.$route.query, }; if (params.sortBy) { - params.ordering = (params.descending ? '-' : '') + params.sortBy; + params.ordering = (String(params.descending) === 'true' ? '-' : '') + params.sortBy; delete params.sortBy; delete params.descending; } diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue index e243a8fd98..491706777c 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelActionsDropdown.vue @@ -146,6 +146,9 @@ channel() { return this.getChannel(this.channelId); }, + name() { + return this.channel.name; + }, searchChannelEditorsLink() { return { name: RouteNames.USERS, diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue index a7029e2710..b867ee36e8 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue @@ -19,7 +19,7 @@ - + This channel has been deleted @@ -102,6 +102,9 @@ channel() { return this.getChannel(this.channelId); }, + isDeleted() { + return this.channel && Boolean(this.channel?.deleted); + }, channelWithDetails() { if (!this.channel || !this.details) { return {}; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue index 8e405ad9c8..5d9ff92c43 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentEditor/AssessmentEditor.vue @@ -12,6 +12,7 @@ { + const questionCards = this.$refs['questionCardRef']; + if (questionCards?.length >= 1) { + const lastQuestionCard = questionCards[questionCards.length - 1].$el; + const editorDiv = document.getElementById('editViewId'); + editorDiv.scrollTo({ + top: lastQuestionCard.offsetTop, + behavior: 'smooth', + }); + } + }); }, async deleteItem(itemToDelete) { if (this.isItemActive(itemToDelete)) { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue index c31c922508..28290e82d6 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue @@ -285,13 +285,6 @@ if (!this.question) { this.openQuestion(); } - // Assessments are nested inside of a scrolling panel. - // Instead of propagating an event all the way back to - // the scrolling panel, just use scrollIntoView - // (supported by most major browsers) - if (this.$el.scrollIntoView) { - this.$el.scrollIntoView({ behaviour: 'smooth' }); - } }, methods: { updateItem(payload) { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue index 6c37c32c3b..cb04a42302 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemToolbar.vue @@ -15,7 +15,7 @@ v-on="on" @click="clickItem(action)" > - + {{ config[action].icon }} diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue index 3e57f48587..8ed1adce13 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeEditListItem.vue @@ -64,6 +64,22 @@ + + + + + + + + + + - import { mapGetters } from 'vuex'; + import { mapGetters, mapActions } from 'vuex'; import ContentNodeListItem from './ContentNodeListItem'; import ContentNodeOptions from './ContentNodeOptions'; @@ -89,9 +105,11 @@ import Checkbox from 'shared/views/form/Checkbox'; import IconButton from 'shared/views/IconButton'; import DraggableItem from 'shared/views/draggable/DraggableItem'; - import { COPYING_FLAG } from 'shared/data/constants'; + import { ContentNode } from 'shared/data/resources'; import { DragEffect, DropEffect, EffectAllowed } from 'shared/mixins/draggable/constants'; import { DraggableRegions } from 'frontend/channelEdit/constants'; + import { withChangeTracker } from 'shared/data/changes'; + import { COPYING_STATUS, COPYING_STATUS_VALUES } from 'shared/data/constants'; export default { name: 'ContentNodeEditListItem', @@ -141,7 +159,11 @@ }, computed: { ...mapGetters('currentChannel', ['canEdit']), - ...mapGetters('contentNode', ['getContentNode']), + ...mapGetters('contentNode', [ + 'getContentNode', + 'isNodeInCopyingState', + 'hasNodeCopyingErrored', + ]), ...mapGetters('draggable', ['activeDraggableRegionId']), selected: { get() { @@ -163,7 +185,10 @@ return !this.copying; }, copying() { - return this.contentNode[COPYING_FLAG]; + return this.isNodeInCopyingState(this.nodeId); + }, + hasCopyingErrored() { + return this.hasNodeCopyingErrored(this.nodeId); }, dragEffect() { return DragEffect.SORT; @@ -206,18 +231,57 @@ this.selected = false; } }, + methods: { + ...mapActions(['showSnackbar', 'clearSnackbar']), + ...mapActions('contentNode', [ + 'updateContentNode', + 'waitForCopyingStatus', + 'deleteContentNode', + ]), + retryFailedCopy: withChangeTracker(function(changeTracker) { + this.updateContentNode({ + id: this.nodeId, + [COPYING_STATUS]: COPYING_STATUS_VALUES.COPYING, + }); + + this.showSnackbar({ + duration: null, + text: this.$tr('creatingCopies'), + // TODO: determine how to cancel copying while it's in progress, + // TODO: if that's something we want + // actionText: this.$tr('cancel'), + // actionCallback: () => changeTracker.revert(), + }); + + ContentNode.retryCopyChange(this.nodeId); + + return this.waitForCopyingStatus({ + contentNodeId: this.nodeId, + startingRev: changeTracker._startingRev, + }) + .then(() => { + this.showSnackbar({ + text: this.$tr('copiedSnackbar'), + actionText: this.$tr('undo'), + actionCallback: () => changeTracker.revert(), + }).then(() => changeTracker.cleanUp()); + }) + .catch(() => { + this.clearSnackbar(); + changeTracker.cleanUp(); + }); + }), + removeFailedCopyNode() { + return this.deleteContentNode(this.nodeId); + }, + }, $trs: { optionsTooltip: 'Options', - /* eslint-disable kolibri/vue-no-unused-translations */ - /** - * Strings for handling copy failures - */ removeNode: 'Remove', retryCopy: 'Retry', creatingCopies: 'Copying...', copiedSnackbar: 'Copy operation complete', undo: 'Undo', - /* eslint-enable kolibri/vue-no-unused-translations */ }, }; @@ -298,4 +362,8 @@ justify-content: center; } + .copy-retry-btn { + font-size: inherit; + } + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.spec.js index 54e1547717..89c7f526fb 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.spec.js @@ -30,7 +30,16 @@ const TOPIC_NODE = { function mountComponent(opts = {}) { return mount(ContentNodeListItem, { - store: createStore(), + store: createStore({ + modules: { + contentNode: { + namespaced: true, + getters: { + isNodeInCopyingState: () => jest.fn(), + }, + }, + }, + }), ...opts, }); } diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue index ba6d555b85..ef85e548b0 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ContentNodeListItem/index.vue @@ -167,10 +167,20 @@ - - {{ $tr("copyingTask") }} + + + {{ copyingMessage }} + + - + + @@ -190,6 +200,7 @@
- {{ $tr("copyingTask") }} +
+ + {{ copyingMessage }} + +