diff --git a/contentcuration/contentcuration/api.py b/contentcuration/contentcuration/api.py
index 33c9692cbc..b297ffaba6 100644
--- a/contentcuration/contentcuration/api.py
+++ b/contentcuration/contentcuration/api.py
@@ -10,9 +10,6 @@
from django.core.files.storage import default_storage
import contentcuration.models as models
-from contentcuration.utils.garbage_collect import get_deleted_chefs_root
-from contentcuration.viewsets.sync.constants import CHANNEL
-from contentcuration.viewsets.sync.utils import generate_update_event
def write_file_to_storage(fobj, check_valid=False, name=None):
@@ -68,33 +65,3 @@ def get_hash(fobj):
md5.update(chunk)
fobj.seek(0)
return md5.hexdigest()
-
-
-def activate_channel(channel, user):
- user.check_channel_space(channel)
-
- if channel.previous_tree and channel.previous_tree != channel.main_tree:
- # IMPORTANT: Do not remove this block, MPTT updating the deleted chefs block could hang the server
- with models.ContentNode.objects.disable_mptt_updates():
- garbage_node = get_deleted_chefs_root()
- channel.previous_tree.parent = garbage_node
- channel.previous_tree.title = "Previous tree for channel {}".format(channel.pk)
- channel.previous_tree.save()
-
- channel.previous_tree = channel.main_tree
- channel.main_tree = channel.staging_tree
- channel.staging_tree = None
- channel.save()
-
- user.staged_files.all().delete()
- user.set_space_used()
-
- models.Change.create_change(generate_update_event(
- channel.id,
- CHANNEL,
- {
- "root_id": channel.main_tree.id,
- "staging_root_id": None
- },
- channel_id=channel.id,
- ), applied=True, created_by_id=user.id)
diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.spec.js b/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.spec.js
index f0d8d37c1c..a37faea02b 100644
--- a/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.spec.js
+++ b/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.spec.js
@@ -2,12 +2,12 @@ import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import VueRouter from 'vue-router';
import cloneDeep from 'lodash/cloneDeep';
-import flushPromises from 'flush-promises';
import { RouteNames } from '../../constants';
import StagingTreePage from './index';
import { createStore } from 'shared/vuex/draggablePlugin/test/setup';
import { ContentKindsNames } from 'shared/leUtils/ContentKinds';
+import { Channel } from 'shared/data/resources';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -217,9 +217,14 @@ describe('StagingTreePage', () => {
];
};
- mockDeployCurrentChannel = jest.fn().mockResolvedValue('');
+ mockDeployCurrentChannel = jest.fn();
actions.currentChannel.deployCurrentChannel = mockDeployCurrentChannel;
- //actions.channel.loadChannel = jest.fn().mockResolvedValue('')
+
+ Channel.waitForDeploying = () => {
+ return new Promise(resolve => {
+ return resolve(ROOT_ID);
+ });
+ };
wrapper = initWrapper({ getters, actions });
// make sure router is reset before each test
@@ -414,9 +419,11 @@ describe('StagingTreePage', () => {
});
it('redirects to a root tree page after deploy channel button click', async () => {
- getDeployDialog(wrapper).vm.$emit('submit');
- await flushPromises();
+ let waitForDeployingSpy = jest.spyOn(Channel, 'waitForDeploying');
+
+ await getDeployDialog(wrapper).vm.$emit('submit');
+ expect(waitForDeployingSpy).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$router.currentRoute.name).toBe(RouteNames.TREE_VIEW);
expect(wrapper.vm.$router.currentRoute.params).toEqual({
nodeId: ROOT_ID,
diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue b/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue
index 21f5fd82b0..a1ccf15c8e 100644
--- a/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue
+++ b/contentcuration/contentcuration/frontend/channelEdit/pages/StagingTreePage/index.vue
@@ -21,7 +21,7 @@
{{ $tr('reviewMode') }}
-
+
@@ -204,8 +204,8 @@
data-test="deploy-dialog"
:title="$tr('deployChannel')"
:submitText="$tr('confirmDeployBtn')"
- :submitDisabled="submitDisabled"
- :cancelDisabled="submitDisabled"
+ :submitDisabled="isDeploying"
+ :cancelDisabled="isDeploying"
:cancelText="$tr('cancelDeployBtn')"
@submit="onDeployChannelClick"
@cancel="displayDeployDialog = false"
@@ -258,6 +258,7 @@
import ToolBar from 'shared/views/ToolBar';
import MainNavigationDrawer from 'shared/views/MainNavigationDrawer';
import OfflineText from 'shared/views/OfflineText';
+ import { Channel } from 'shared/data/resources';
export default {
name: 'StagingTreePage',
@@ -295,7 +296,7 @@
displayDeployDialog: false,
drawer: false,
elevated: false,
- submitDisabled: false,
+ isDeploying: false,
};
},
computed: {
@@ -403,9 +404,11 @@
});
},
stagingId() {
- this.$router.push({
- name: RouteNames.STAGING_TREE_VIEW_REDIRECT,
- });
+ if (this.hasStagingTree) {
+ this.$router.push({
+ name: RouteNames.STAGING_TREE_VIEW_REDIRECT,
+ });
+ }
},
},
created() {
@@ -432,7 +435,6 @@
},
methods: {
...mapActions(['showSnackbar', 'addViewModeOverride', 'removeViewModeOverride']),
- ...mapActions('channel', ['loadChannel']),
...mapActions('currentChannel', [
'loadCurrentChannelStagingDiff',
'deployCurrentChannel',
@@ -506,21 +508,24 @@
scroll(e) {
this.elevated = e.target.scrollTop > 0;
},
- async onDeployChannelClick() {
- this.submitDisabled = true;
- try {
- await this.deployCurrentChannel();
- } catch (e) {
- this.submitDisabled = false;
- throw e;
- }
- await this.loadChannel(this.currentChannel.id);
+ onDeployChannelClick() {
+ this.displayDeployDialog = false;
+ this.isDeploying = true;
- this.$router.push(this.rootTreeRoute);
-
- this.showSnackbar({
- text: this.$tr('channelDeployed'),
+ Channel.waitForDeploying(this.currentChannel.id).then(rootId => {
+ this.isDeploying = false;
+ this.$router.push({
+ name: RouteNames.TREE_VIEW,
+ params: {
+ nodeId: rootId,
+ },
+ });
+ this.showSnackbar({
+ text: this.$tr('channelDeployed'),
+ });
});
+
+ this.deployCurrentChannel();
},
},
$trs: {
diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/actions.js
index dd5bb6139a..9dca09a0a2 100644
--- a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/actions.js
+++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/actions.js
@@ -48,15 +48,7 @@ export function reloadCurrentChannelStagingDiff(context) {
}
export function deployCurrentChannel(context) {
- let payload = {
- channel_id: context.state.currentChannelId,
- };
- return client.post(window.Urls.activate_channel(), payload).catch(e => {
- // If response is 'Bad request', channel must already be activated
- if (e.response && e.response.status === 400) {
- return Promise.resolve();
- }
- });
+ return Channel.deploy(context.state.currentChannelId);
}
export function publishChannel(context, version_notes) {
diff --git a/contentcuration/contentcuration/frontend/shared/data/applyRemoteChanges.js b/contentcuration/contentcuration/frontend/shared/data/applyRemoteChanges.js
index 3e5b8a79d9..4788f302d6 100644
--- a/contentcuration/contentcuration/frontend/shared/data/applyRemoteChanges.js
+++ b/contentcuration/contentcuration/frontend/shared/data/applyRemoteChanges.js
@@ -4,7 +4,7 @@ import { CHANGE_TYPES, IGNORED_SOURCE, TABLE_NAMES } from './constants';
import db from './db';
import { INDEXEDDB_RESOURCES } from './registry';
-const { CREATED, DELETED, UPDATED, MOVED, PUBLISHED, SYNCED } = CHANGE_TYPES;
+const { CREATED, DELETED, UPDATED, MOVED, PUBLISHED, SYNCED, DEPLOYED } = CHANGE_TYPES;
export function applyMods(obj, mods) {
for (let keyPath in mods) {
@@ -28,6 +28,7 @@ export function collectChanges(changes) {
[MOVED]: [],
[PUBLISHED]: [],
[SYNCED]: [],
+ [DEPLOYED]: [],
};
}
collectedChanges[change.table][change.type].push(change);
diff --git a/contentcuration/contentcuration/frontend/shared/data/constants.js b/contentcuration/contentcuration/frontend/shared/data/constants.js
index 8e658c0ecb..bbc35582a3 100644
--- a/contentcuration/contentcuration/frontend/shared/data/constants.js
+++ b/contentcuration/contentcuration/frontend/shared/data/constants.js
@@ -6,6 +6,7 @@ export const CHANGE_TYPES = {
COPIED: 5,
PUBLISHED: 6,
SYNCED: 7,
+ DEPLOYED: 8,
};
/**
* An array of change types that directly result in the creation of nodes
diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js
index ac51f15330..e33a6860a7 100644
--- a/contentcuration/contentcuration/frontend/shared/data/resources.js
+++ b/contentcuration/contentcuration/frontend/shared/data/resources.js
@@ -1085,6 +1085,44 @@ export const Channel = new Resource({
});
},
+ deploy(id) {
+ const change = {
+ key: id,
+ source: CLIENTID,
+ table: this.tableName,
+ type: CHANGE_TYPES.DEPLOYED,
+ channel_id: id,
+ };
+ return this.transaction({ mode: 'rw', source: IGNORED_SOURCE }, CHANGES_TABLE, () => {
+ return db[CHANGES_TABLE].put(change);
+ });
+ },
+
+ waitForDeploying(id) {
+ const observable = Dexie.liveQuery(() => {
+ return this.table
+ .where('id')
+ .equals(id)
+ .filter(channel => !channel['staging_root_id'] || channel['staging_root_id'] === null)
+ .toArray();
+ });
+
+ return new Promise((resolve, reject) => {
+ const subscription = observable.subscribe({
+ next(result) {
+ if (result.length === 1) {
+ subscription.unsubscribe();
+ resolve(result[0].root_id);
+ }
+ },
+ error() {
+ subscription.unsubscribe();
+ reject();
+ },
+ });
+ });
+ },
+
sync(id, { attributes = false, tags = false, files = false, assessment_items = false } = {}) {
const change = {
key: id,
diff --git a/contentcuration/contentcuration/frontend/shared/data/serverSync.js b/contentcuration/contentcuration/frontend/shared/data/serverSync.js
index fa7e9b5aa3..7131e2ee06 100644
--- a/contentcuration/contentcuration/frontend/shared/data/serverSync.js
+++ b/contentcuration/contentcuration/frontend/shared/data/serverSync.js
@@ -54,6 +54,7 @@ const ChangeTypeMapFields = {
]),
[CHANGE_TYPES.PUBLISHED]: commonFields.concat(['version_notes', 'language']),
[CHANGE_TYPES.SYNCED]: commonFields.concat(['attributes', 'tags', 'files', 'assessment_items']),
+ [CHANGE_TYPES.DEPLOYED]: commonFields,
};
function isSyncableChange(change) {
diff --git a/contentcuration/contentcuration/tests/utils/test_garbage_collect.py b/contentcuration/contentcuration/tests/utils/test_garbage_collect.py
index e4c9941df4..a718a408c2 100644
--- a/contentcuration/contentcuration/tests/utils/test_garbage_collect.py
+++ b/contentcuration/contentcuration/tests/utils/test_garbage_collect.py
@@ -16,7 +16,6 @@
from le_utils.constants import format_presets
from contentcuration import models as cc
-from contentcuration.api import activate_channel
from contentcuration.constants import user_history
from contentcuration.models import ContentNode
from contentcuration.models import File
@@ -146,35 +145,6 @@ def test_old_staging_tree(self):
self.assertFalse(cc.ContentNode.objects.filter(parent=garbage_node).exists())
self.assertFalse(cc.ContentNode.objects.filter(pk=child_pk).exists())
- def test_activate_channel(self):
- previous_tree = self.channel.previous_tree
- tree(parent=previous_tree)
- garbage_node = get_deleted_chefs_root()
-
- # Previous tree shouldn't be in garbage tree until activate_channel is called
- self.assertFalse(
- garbage_node.get_descendants().filter(pk=previous_tree.pk).exists()
- )
- activate_channel(self.channel, self.user)
- garbage_node.refresh_from_db()
- previous_tree.refresh_from_db()
- self.channel.refresh_from_db()
-
- # We can't use MPTT methods on the deleted chefs tree because we are not running the sort code
- # for performance reasons, so just do a parent test instead.
- self.assertTrue(previous_tree.parent == garbage_node)
-
- # New previous tree should not be in garbage tree
- self.assertFalse(self.channel.previous_tree.parent)
- self.assertNotEqual(garbage_node.tree_id, self.channel.previous_tree.tree_id)
-
- child_pk = previous_tree.children.first().pk
-
- clean_up_deleted_chefs()
-
- self.assertFalse(cc.ContentNode.objects.filter(parent=garbage_node).exists())
- self.assertFalse(cc.ContentNode.objects.filter(pk=child_pk).exists())
-
THREE_MONTHS_AGO = datetime.now() - timedelta(days=93)
diff --git a/contentcuration/contentcuration/tests/views/test_views_base.py b/contentcuration/contentcuration/tests/views/test_views_base.py
index 201058d289..74d0633971 100644
--- a/contentcuration/contentcuration/tests/views/test_views_base.py
+++ b/contentcuration/contentcuration/tests/views/test_views_base.py
@@ -5,47 +5,10 @@
from django.urls import reverse_lazy
from ..base import BaseAPITestCase
-from contentcuration.models import Channel
from contentcuration.models import TaskResult
from contentcuration.utils.db_tools import TreeBuilder
-class APIActivateChannelEndpointTestCase(BaseAPITestCase):
- def test_200_post(self):
- main_tree = TreeBuilder(user=self.user)
- staging_tree = TreeBuilder(user=self.user)
- self.channel.main_tree = main_tree.root
- self.channel.staging_tree = staging_tree.root
- self.channel.save()
- response = self.post(
- reverse_lazy("activate_channel"), {"channel_id": self.channel.id}
- )
- self.assertEqual(response.status_code, 200)
-
- def test_404_no_permission(self):
- new_channel = Channel.objects.create()
- staging_tree = TreeBuilder(user=self.user, levels=1)
- new_channel.staging_tree = staging_tree.root
- new_channel.save()
- response = self.post(
- reverse_lazy("activate_channel"), {"channel_id": new_channel.id}
- )
- self.assertEqual(response.status_code, 404)
-
- def test_200_no_change_in_space(self):
- main_tree = TreeBuilder(user=self.user)
- staging_tree = TreeBuilder(user=self.user)
- self.channel.main_tree = main_tree.root
- self.channel.staging_tree = staging_tree.root
- self.channel.save()
- self.user.disk_space = self.user.get_space_used(active_files=self.user.get_user_active_files())
- self.user.save()
- response = self.post(
- reverse_lazy("activate_channel"), {"channel_id": self.channel.id}
- )
- self.assertEqual(response.status_code, 200)
-
-
class PublishingStatusEndpointTestCase(BaseAPITestCase):
def test_200_get(self):
self.user.is_admin = True
diff --git a/contentcuration/contentcuration/tests/views/test_views_internal.py b/contentcuration/contentcuration/tests/views/test_views_internal.py
index 999cc9a7b6..0a40ab2136 100644
--- a/contentcuration/contentcuration/tests/views/test_views_internal.py
+++ b/contentcuration/contentcuration/tests/views/test_views_internal.py
@@ -613,23 +613,6 @@ def test_404_no_permission(self):
self.assertEqual(response.status_code, 404)
-class APIActivateChannelEndpointTestCase(BaseAPITestCase):
- def test_200_post(self):
- self.channel.staging_tree = self.channel.main_tree
- self.channel.save()
- response = self.post(
- reverse_lazy("activate_channel_internal"), {"channel_id": self.channel.id}
- )
- self.assertEqual(response.status_code, 200)
-
- def test_404_no_permission(self):
- new_channel = Channel.objects.create()
- response = self.post(
- reverse_lazy("activate_channel_internal"), {"channel_id": new_channel.id}
- )
- self.assertEqual(response.status_code, 404)
-
-
class CheckUserIsEditorEndpointTestCase(BaseAPITestCase):
def test_200_post(self):
response = self.post(
diff --git a/contentcuration/contentcuration/tests/viewsets/base.py b/contentcuration/contentcuration/tests/viewsets/base.py
index 48ddd1c995..7dd55c9622 100644
--- a/contentcuration/contentcuration/tests/viewsets/base.py
+++ b/contentcuration/contentcuration/tests/viewsets/base.py
@@ -11,6 +11,7 @@
from contentcuration.viewsets.sync.utils import generate_copy_event as base_generate_copy_event
from contentcuration.viewsets.sync.utils import generate_create_event as base_generate_create_event
from contentcuration.viewsets.sync.utils import generate_delete_event as base_generate_delete_event
+from contentcuration.viewsets.sync.utils import generate_deploy_event as base_generate_deploy_event
from contentcuration.viewsets.sync.utils import generate_update_event as base_generate_update_event
@@ -48,6 +49,12 @@ def generate_sync_channel_event(channel_id, attributes, tags, files, assessment_
return event
+def generate_deploy_channel_event(channel_id, user_id):
+ event = base_generate_deploy_event(channel_id, user_id=user_id)
+ event["rev"] = random.randint(1, 10000000)
+ return event
+
+
class SyncTestMixin(object):
celery_task_always_eager = None
diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py
index b88f54ad98..02e8b6571f 100644
--- a/contentcuration/contentcuration/tests/viewsets/test_channel.py
+++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py
@@ -4,12 +4,15 @@
import mock
from django.urls import reverse
+from le_utils.constants import content_kinds
from contentcuration import models
+from contentcuration import models as cc
from contentcuration.tests import testdata
from contentcuration.tests.base import StudioAPITestCase
from contentcuration.tests.viewsets.base import generate_create_event
from contentcuration.tests.viewsets.base import generate_delete_event
+from contentcuration.tests.viewsets.base import generate_deploy_channel_event
from contentcuration.tests.viewsets.base import generate_sync_channel_event
from contentcuration.tests.viewsets.base import generate_update_event
from contentcuration.tests.viewsets.base import SyncTestMixin
@@ -299,6 +302,75 @@ def test_sync_channel_called_correctly(self, sync_channel_mock):
self.assertEqual(sync_channel_mock.call_args.args[i], True)
sync_channel_mock.assert_called_once()
+ def test_deploy_channel_event(self):
+ channel = testdata.channel()
+ user = testdata.user()
+ channel.editors.add(user)
+ self.client.force_authenticate(
+ user
+ ) # This will skip all authentication checks
+ channel.main_tree.refresh_from_db()
+
+ channel.staging_tree = cc.ContentNode(
+ kind_id=content_kinds.TOPIC, title="test", node_id="aaa"
+ )
+ channel.staging_tree.save()
+ channel.previous_tree = cc.ContentNode(
+ kind_id=content_kinds.TOPIC, title="test", node_id="bbb"
+ )
+ channel.previous_tree.save()
+ channel.chef_tree = cc.ContentNode(
+ kind_id=content_kinds.TOPIC, title="test", node_id="ccc"
+ )
+ channel.chef_tree.save()
+ channel.save()
+
+ self.contentnode = cc.ContentNode.objects.create(kind_id="video")
+
+ response = self.sync_changes(
+ [
+ generate_deploy_channel_event(channel.id, user.id)
+ ]
+ )
+
+ self.assertEqual(response.status_code, 200)
+ modified_channel = models.Channel.objects.get(id=channel.id)
+ self.assertEqual(modified_channel.main_tree, channel.staging_tree)
+ self.assertEqual(modified_channel.staging_tree, None)
+ self.assertEqual(modified_channel.previous_tree, channel.main_tree)
+
+ def test_deploy_with_staging_tree_None(self):
+ channel = testdata.channel()
+ user = testdata.user()
+ channel.editors.add(user)
+ self.client.force_authenticate(
+ user
+ ) # This will skip all authentication checks
+ channel.main_tree.refresh_from_db()
+
+ channel.staging_tree = None
+ channel.previous_tree = cc.ContentNode(
+ kind_id=content_kinds.TOPIC, title="test", node_id="bbb"
+ )
+ channel.previous_tree.save()
+ channel.chef_tree = cc.ContentNode(
+ kind_id=content_kinds.TOPIC, title="test", node_id="ccc"
+ )
+ channel.chef_tree.save()
+ channel.save()
+
+ self.contentnode = cc.ContentNode.objects.create(kind_id="video")
+ response = self.sync_changes(
+ [
+ generate_deploy_channel_event(channel.id, user.id)
+ ]
+ )
+ # Should raise validation error as staging tree was set to NONE
+ self.assertEqual(len(response.json()["errors"]), 1, response.content)
+ modified_channel = models.Channel.objects.get(id=channel.id)
+ self.assertNotEqual(modified_channel.main_tree, channel.staging_tree)
+ self.assertNotEqual(modified_channel.previous_tree, channel.main_tree)
+
class CRUDTestCase(StudioAPITestCase):
@property
diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py
index fbf007d5e7..bb03f3876e 100644
--- a/contentcuration/contentcuration/urls.py
+++ b/contentcuration/contentcuration/urls.py
@@ -71,7 +71,6 @@ def get_redirect_url(self, *args, **kwargs):
urlpatterns = [
re_path(r'^api/', include(router.urls)),
re_path(r'^serviceWorker.js$', pwa.ServiceWorkerView.as_view(), name="service_worker"),
- re_path(r'^api/activate_channel$', views.activate_channel_endpoint, name='activate_channel'),
re_path(r'^healthz$', views.health, name='health'),
re_path(r'^stealthz$', views.stealth, name='stealth'),
re_path(r'^api/search/', include('search.urls'), name='search'),
@@ -124,7 +123,6 @@ def get_redirect_url(self, *args, **kwargs):
re_path(r'^api/internal/file_diff$', internal_views.file_diff, name="file_diff"),
re_path(r'^api/internal/file_upload$', internal_views.api_file_upload, name="api_file_upload"),
re_path(r'^api/internal/publish_channel$', internal_views.api_publish_channel, name="api_publish_channel"),
- re_path(r'^api/internal/activate_channel_internal$', internal_views.activate_channel_internal, name='activate_channel_internal'),
re_path(r'^api/internal/check_user_is_editor$', internal_views.check_user_is_editor, name='check_user_is_editor'),
re_path(r'^api/internal/get_tree_data$', internal_views.get_tree_data, name='get_tree_data'),
re_path(r'^api/internal/get_node_tree_data$', internal_views.get_node_tree_data, name='get_node_tree_data'),
diff --git a/contentcuration/contentcuration/views/base.py b/contentcuration/contentcuration/views/base.py
index 158ab54068..1f0eb999d4 100644
--- a/contentcuration/contentcuration/views/base.py
+++ b/contentcuration/contentcuration/views/base.py
@@ -40,7 +40,6 @@
from .json_dump import json_for_parse_from_data
from .json_dump import json_for_parse_from_serializer
-from contentcuration.api import activate_channel
from contentcuration.constants import channel_history
from contentcuration.decorators import browser_is_supported
from contentcuration.models import Change
@@ -359,25 +358,6 @@ class SQCountDistinct(Subquery):
output_field = IntegerField()
-@api_view(['POST'])
-@authentication_classes((SessionAuthentication,))
-@permission_classes((IsAuthenticated,))
-def activate_channel_endpoint(request):
- data = request.data
- try:
- channel = Channel.filter_edit_queryset(Channel.objects.all(), request.user).get(pk=data["channel_id"])
- except Channel.DoesNotExist:
- return HttpResponseNotFound("Channel not found")
- if channel.staging_tree is None:
- return HttpResponseBadRequest('Channel is not staged')
- try:
- activate_channel(channel, request.user)
- except PermissionDenied as e:
- return HttpResponseForbidden(str(e))
-
- return HttpResponse(json.dumps({"success": True}))
-
-
@require_POST
# flake8: noqa: C901
def set_language(request):
diff --git a/contentcuration/contentcuration/views/internal.py b/contentcuration/contentcuration/views/internal.py
index ccbc358e5e..acb3578ce0 100644
--- a/contentcuration/contentcuration/views/internal.py
+++ b/contentcuration/contentcuration/views/internal.py
@@ -2,8 +2,8 @@
import logging
from builtins import str
from collections import namedtuple
-from distutils.version import LooseVersion
+from distutils.version import LooseVersion
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import PermissionDenied
from django.core.exceptions import SuspiciousOperation
@@ -34,7 +34,6 @@
from sentry_sdk import capture_exception
from contentcuration import ricecooker_versions as rc
-from contentcuration.api import activate_channel
from contentcuration.api import write_file_to_storage
from contentcuration.constants import completion_criteria
from contentcuration.decorators import delay_user_storage_calculation
@@ -340,23 +339,6 @@ def api_publish_channel(request):
return HttpResponseServerError(content=str(e), reason=str(e))
-@api_view(['POST'])
-@authentication_classes((TokenAuthentication,))
-@permission_classes((IsAuthenticated,))
-def activate_channel_internal(request):
- try:
- data = json.loads(request.body)
- channel_id = data['channel_id']
- channel = Channel.get_editable(request.user, channel_id)
- activate_channel(channel, request.user)
- return Response({"success": True})
- except Channel.DoesNotExist:
- return HttpResponseNotFound("No channel matching: {}".format(channel_id))
- except Exception as e:
- handle_server_error(e, request)
- return HttpResponseServerError(content=str(e), reason=str(e))
-
-
@api_view(["POST"])
@authentication_classes((TokenAuthentication, SessionAuthentication,))
@permission_classes((IsAuthenticated,))
diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py
index 773bc0b740..5c3b1fb2c1 100644
--- a/contentcuration/contentcuration/viewsets/channel.py
+++ b/contentcuration/contentcuration/viewsets/channel.py
@@ -28,6 +28,7 @@
from search.models import ContentNodeFullTextSearch
from search.utils import get_fts_search_query
+import contentcuration.models as models
from contentcuration.decorators import cache_no_user_data
from contentcuration.models import Change
from contentcuration.models import Channel
@@ -36,6 +37,7 @@
from contentcuration.models import generate_storage_url
from contentcuration.models import SecretToken
from contentcuration.models import User
+from contentcuration.utils.garbage_collect import get_deleted_chefs_root
from contentcuration.utils.pagination import CachedListPagination
from contentcuration.utils.pagination import ValuesViewsetPageNumberPagination
from contentcuration.utils.publish import publish_channel
@@ -558,6 +560,52 @@ def sync(self, pk, attributes=False, tags=False, files=False, assessment_items=F
progress_tracker=progress_tracker,
)
+ def deploy_from_changes(self, changes):
+ errors = []
+ for deploy in changes:
+ try:
+ self.deploy(self.request.user, deploy["key"])
+ except Exception as e:
+ log_sync_exception(e, user=self.request.user, change=deploy)
+ deploy["errors"] = [str(e)]
+ errors.append(deploy)
+ return errors
+
+ def deploy(self, user, pk):
+
+ channel = self.get_edit_queryset().get(pk=pk)
+
+ if channel.staging_tree is None:
+ raise ValidationError("Cannot deploy a channel without staging tree")
+
+ user.check_channel_space(channel)
+
+ if channel.previous_tree and channel.previous_tree != channel.main_tree:
+ # IMPORTANT: Do not remove this block, MPTT updating the deleted chefs block could hang the server
+ with models.ContentNode.objects.disable_mptt_updates():
+ garbage_node = get_deleted_chefs_root()
+ channel.previous_tree.parent = garbage_node
+ channel.previous_tree.title = "Previous tree for channel {}".format(channel.pk)
+ channel.previous_tree.save()
+
+ channel.previous_tree = channel.main_tree
+ channel.main_tree = channel.staging_tree
+ channel.staging_tree = None
+ channel.save()
+
+ user.staged_files.all().delete()
+ user.set_space_used()
+
+ models.Change.create_change(generate_update_event(
+ channel.id,
+ CHANNEL,
+ {
+ "root_id": channel.main_tree.id,
+ "staging_root_id": None
+ },
+ channel_id=channel.id,
+ ), applied=True, created_by_id=user.id)
+
@method_decorator(
cache_page(
diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py
index 455a97e4aa..f11a8f4729 100644
--- a/contentcuration/contentcuration/viewsets/sync/base.py
+++ b/contentcuration/contentcuration/viewsets/sync/base.py
@@ -22,6 +22,7 @@
from contentcuration.viewsets.sync.constants import COPIED
from contentcuration.viewsets.sync.constants import CREATED
from contentcuration.viewsets.sync.constants import DELETED
+from contentcuration.viewsets.sync.constants import DEPLOYED
from contentcuration.viewsets.sync.constants import EDITOR_M2M
from contentcuration.viewsets.sync.constants import FILE
from contentcuration.viewsets.sync.constants import INVITATION
@@ -92,6 +93,7 @@ def get_change_type(obj):
COPIED: "copy_from_changes",
PUBLISHED: "publish_from_changes",
SYNCED: "sync_from_changes",
+ DEPLOYED: "deploy_from_changes",
}
diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py
index 6e553b6ccd..84c2b5aad7 100644
--- a/contentcuration/contentcuration/viewsets/sync/constants.py
+++ b/contentcuration/contentcuration/viewsets/sync/constants.py
@@ -6,6 +6,7 @@
COPIED = 5
PUBLISHED = 6
SYNCED = 7
+DEPLOYED = 8
ALL_CHANGES = set([
@@ -16,6 +17,7 @@
COPIED,
PUBLISHED,
SYNCED,
+ DEPLOYED,
])
# Client-side table constants
diff --git a/contentcuration/contentcuration/viewsets/sync/utils.py b/contentcuration/contentcuration/viewsets/sync/utils.py
index 1d3718b5e2..47b5a17e54 100644
--- a/contentcuration/contentcuration/viewsets/sync/utils.py
+++ b/contentcuration/contentcuration/viewsets/sync/utils.py
@@ -6,6 +6,7 @@
from contentcuration.viewsets.sync.constants import COPIED
from contentcuration.viewsets.sync.constants import CREATED
from contentcuration.viewsets.sync.constants import DELETED
+from contentcuration.viewsets.sync.constants import DEPLOYED
from contentcuration.viewsets.sync.constants import MOVED
from contentcuration.viewsets.sync.constants import PUBLISHED
from contentcuration.viewsets.sync.constants import UPDATED
@@ -74,6 +75,11 @@ def generate_publish_event(
return event
+def generate_deploy_event(key, user_id):
+ event = _generate_event(key, CHANNEL, DEPLOYED, channel_id=key, user_id=user_id)
+ return event
+
+
def log_sync_exception(e, user=None, change=None, changes=None):
# Capture exception and report, but allow sync
# to complete properly.
diff --git a/docs/api_endpoints.md b/docs/api_endpoints.md
index 094a018f5f..9d2de4a9f4 100644
--- a/docs/api_endpoints.md
+++ b/docs/api_endpoints.md
@@ -199,24 +199,6 @@ Response:
}
-
-### api/internal/activate_channel_internal
-_Method: contentcuration.views.internal.activate_channel_internal_
-Deploys a staged channel to the live channel
-
- POST api/internal/activate_channel_internal
- Header: ---
- Body:
- {"channel_id": "{uuid.hex}"}
-
-Response:
-
- {
- "success": True
- }
-
-
-
### api/internal/get_tree_data
_Method: contentcuration.views.internal.get_tree_data_
Returns the complete tree hierarchy information (for tree specified in `tree`).
@@ -309,18 +291,6 @@ Response:
Channel endpoints
--------------------------
-### api/activate_channel
-_Method: contentcuration.views.base.activate_channel_endpoint_
-Moves the channel's staging tree to the main tree
-
- POST api/activate_channel
- Body: {"channel_id": "{uuid.hex}"}
- Response:
-
- {
- "success": true
- }
-
### api/get_staged_diff_endpoint
_Method: contentcuration.views.base.get_staged_diff_endpoint_