diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelItem.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelItem.vue
index ae440d54ab..1b94439e23 100644
--- a/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelItem.vue
+++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelItem.vue
@@ -190,7 +190,9 @@
icon="trash"
/>
- {{ $tr('deleteChannel') }}
+
+ {{ canEdit ? $tr('deleteChannel') : $tr('removeChannel') }}
+
@@ -202,14 +204,14 @@
- {{ $tr('deletePrompt') }}
+ {{ canEdit ? $tr('deletePrompt') : $tr('removePrompt') }}
{
- this.deleteDialog = false;
- this.$store.dispatch('showSnackbarSimple', this.$tr('channelDeletedSnackbar'));
- });
+ if (!this.canEdit) {
+ const currentUserId = this.$store.state.session.currentUser.id;
+ this.removeViewer({ channelId: this.channelId, userId: currentUserId }).then(() => {
+ this.deleteDialog = false;
+ this.$store.dispatch('showSnackbarSimple', this.$tr('channelRemovedSnackbar'));
+ });
+ } else {
+ this.deleteChannel(this.channelId).then(() => {
+ this.deleteDialog = false;
+ this.$store.dispatch('showSnackbarSimple', this.$tr('channelDeletedSnackbar'));
+ });
+ }
},
goToChannelRoute() {
this.linkToChannelTree
@@ -374,8 +384,14 @@
copyToken: 'Copy channel token',
deleteChannel: 'Delete channel',
deleteTitle: 'Delete this channel',
+ removeChannel: 'Remove from channel list',
+ removeBtn: 'Remove',
+ removeTitle: 'Remove from channel list',
deletePrompt: 'This channel will be permanently deleted. This cannot be undone.',
+ removePrompt:
+ 'You have view-only access to this channel. Confirm that you want to remove it from your list of channels.',
channelDeletedSnackbar: 'Channel deleted',
+ channelRemovedSnackbar: 'Channel removed',
channelLanguageNotSetIndicator: 'No language set',
cancel: 'Cancel',
},
diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/channelItem.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/channelItem.spec.js
index 79bcea297e..1e8b9d63d4 100644
--- a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/channelItem.spec.js
+++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/channelItem.spec.js
@@ -76,11 +76,37 @@ describe('channelItem', () => {
wrapper.find('[data-test="token-listitem"]').trigger('click');
expect(wrapper.vm.tokenDialog).toBe(true);
});
- it('clicking delete button in dialog should delete the channel', () => {
+ it('when user can edit, clicking delete button in dialog should call deleteChannel', async () => {
+ const deleteChannelSpy = jest.fn().mockResolvedValue();
+ const removeViewerSpy = jest.fn().mockResolvedValue();
+ wrapper = makeWrapper(true, deleteStub);
+ wrapper.setMethods({
+ deleteChannel: deleteChannelSpy,
+ removeViewer: removeViewerSpy,
+ });
+
+ wrapper.setData({ deleteDialog: true });
+ wrapper.find('[data-test="delete-modal"]').trigger('submit');
+ await wrapper.vm.$nextTick(() => {
+ expect(deleteChannelSpy).toHaveBeenCalledWith(channelId);
+ expect(removeViewerSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ it('when user cannot edit, clicking delete button in dialog should call removeViewer', async () => {
+ const deleteChannelSpy = jest.fn().mockResolvedValue();
+ const removeViewerSpy = jest.fn().mockResolvedValue();
+ wrapper = makeWrapper(false, deleteStub);
+ wrapper.setMethods({
+ deleteChannel: deleteChannelSpy,
+ removeViewer: removeViewerSpy,
+ });
+
wrapper.setData({ deleteDialog: true });
wrapper.find('[data-test="delete-modal"]').trigger('submit');
- wrapper.vm.$nextTick(() => {
- expect(deleteStub).toHaveBeenCalled();
+ await wrapper.vm.$nextTick(() => {
+ expect(removeViewerSpy).toHaveBeenCalledWith({ channelId, userId: 0 });
+ expect(deleteChannelSpy).not.toHaveBeenCalled();
});
});
diff --git a/contentcuration/contentcuration/frontend/shared/data/__tests__/resources.spec.js b/contentcuration/contentcuration/frontend/shared/data/__tests__/resources.spec.js
index 34b285e48e..4139dccb3f 100644
--- a/contentcuration/contentcuration/frontend/shared/data/__tests__/resources.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/data/__tests__/resources.spec.js
@@ -1,9 +1,11 @@
import { UpdatedDescendantsChange } from '../changes';
+import { ViewerM2M, ChannelUser, Channel, ContentNode } from '../resources';
import db from 'shared/data/db';
import { CHANGE_TYPES, TABLE_NAMES } from 'shared/data/constants';
import { ContentKindsNames } from 'shared/leUtils/ContentKinds';
-import { ContentNode } from 'shared/data/resources';
import { mockChannelScope, resetMockChannelScope } from 'shared/utils/testing';
+import client from 'shared/client';
+import urls from 'shared/urls';
const CLIENTID = 'test-client-id';
@@ -170,5 +172,53 @@ describe('Resources', () => {
expect(change.mods).toEqual(changes);
});
});
+ describe('ChannelUser resource', () => {
+ const testChannelId = 'test-channel-id';
+ const testUserId = 'test-user-id';
+
+ beforeEach(async () => {
+ await db[TABLE_NAMES.VIEWER_M2M].clear();
+ await db[TABLE_NAMES.CHANNEL].clear();
+ jest.spyOn(client, 'delete').mockResolvedValue({});
+ jest.spyOn(Channel.table, 'delete').mockResolvedValue(true);
+ jest.spyOn(urls, 'channeluser_remove_self').mockReturnValue(`fake_url_for_${testUserId}`);
+ });
+
+ afterEach(() => {
+ client.delete.mockRestore();
+ Channel.table.delete.mockRestore();
+ urls.channeluser_remove_self.mockRestore();
+ });
+
+ it('should remove the user from the ViewerM2M table when removeViewer is called', async () => {
+ await ViewerM2M.add({ user: testUserId, channel: testChannelId });
+ let viewer = await ViewerM2M.get([testUserId, testChannelId]);
+ expect(viewer).toBeTruthy();
+
+ await ChannelUser.removeViewer(testChannelId, testUserId);
+
+ viewer = await ViewerM2M.get([testUserId, testChannelId]);
+ expect(viewer).toBeUndefined();
+ expect(client.delete).toHaveBeenCalledWith(urls.channeluser_remove_self(testUserId), {
+ params: { channel_id: testChannelId },
+ });
+ });
+
+ it('should call Channel.table.delete(channel) when removeViewer is called', async () => {
+ await ViewerM2M.add({ user: testUserId, channel: testChannelId });
+ const viewer = await ViewerM2M.get([testUserId, testChannelId]);
+ expect(viewer).toBeTruthy();
+ await ChannelUser.removeViewer(testChannelId, testUserId);
+ expect(Channel.table.delete).toHaveBeenCalledWith(testChannelId);
+ });
+
+ it('should handle error from client.delete when removeViewer is called', async () => {
+ jest.spyOn(client, 'delete').mockRejectedValue(new Error('error deleting'));
+ await ViewerM2M.add({ user: testUserId, channel: testChannelId });
+ await expect(ChannelUser.removeViewer(testChannelId, testUserId)).rejects.toThrow(
+ 'error deleting'
+ );
+ });
+ });
});
});
diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js
index 6f180dc08b..ad09f33fee 100644
--- a/contentcuration/contentcuration/frontend/shared/data/resources.js
+++ b/contentcuration/contentcuration/frontend/shared/data/resources.js
@@ -2058,7 +2058,14 @@ export const ChannelUser = new APIResource({
});
},
removeViewer(channel, user) {
- return ViewerM2M.delete([user, channel]);
+ const modelUrl = urls.channeluser_remove_self(user);
+ const params = { channel_id: channel };
+ return ViewerM2M.delete([user, channel])
+ .then(() => client.delete(modelUrl, { params }))
+ .then(() => Channel.table.delete(channel))
+ .catch(err => {
+ throw err;
+ });
},
fetchCollection(params) {
return client.get(this.collectionUrl(), { params }).then(response => {
diff --git a/contentcuration/contentcuration/viewsets/user.py b/contentcuration/contentcuration/viewsets/user.py
index 766e3097a0..fa9d98d85f 100644
--- a/contentcuration/contentcuration/viewsets/user.py
+++ b/contentcuration/contentcuration/viewsets/user.py
@@ -11,6 +11,9 @@
from django.db.models import Value
from django.db.models.functions import Cast
from django.db.models.functions import Concat
+from django.http import HttpResponseBadRequest
+from django.http.response import HttpResponseForbidden
+from django.http.response import HttpResponseNotFound
from django_filters.rest_framework import BooleanFilter
from django_filters.rest_framework import CharFilter
from django_filters.rest_framework import FilterSet
@@ -19,6 +22,7 @@
from rest_framework.permissions import BasePermission
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
+from rest_framework.status import HTTP_204_NO_CONTENT
from contentcuration.constants import feature_flags
from contentcuration.models import boolean_val
@@ -267,6 +271,30 @@ def create_from_changes(self, changes):
def delete_from_changes(self, changes):
return self._handle_relationship_changes(changes)
+ @action(detail=True, methods=['delete'])
+ def remove_self(self, request, pk=None):
+ """
+ Allows a user to remove themselves from a channel as a viewer.
+ """
+ user = self.get_object()
+ channel_id = request.query_params.get('channel_id', None)
+
+ if not channel_id:
+ return HttpResponseBadRequest('Channel ID is required.')
+
+ channel = Channel.objects.get(id=channel_id)
+ if not channel:
+ return HttpResponseNotFound("Channel not found {}".format(channel_id))
+
+ if request.user != user and not request.user.can_edit(channel_id):
+ return HttpResponseForbidden("You do not have permission to remove this user {}".format(user.id))
+
+ if channel.viewers.filter(id=user.id).exists():
+ channel.viewers.remove(user)
+ return Response(status=HTTP_204_NO_CONTENT)
+ else:
+ return HttpResponseBadRequest('User is not a viewer of this channel.')
+
class AdminUserFilter(FilterSet):
keywords = CharFilter(method="filter_keywords")