Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@
icon="trash"
/>
</VListTileAvatar>
<VListTileTitle>{{ $tr('deleteChannel') }}</VListTileTitle>
<VListTileTitle>
{{ canEdit ? $tr('deleteChannel') : $tr('removeChannel') }}
</VListTileTitle>
</VListTile>
</VList>
</Menu>
Expand All @@ -202,14 +204,14 @@
<!-- Delete dialog -->
<KModal
v-if="deleteDialog"
:title="$tr('deleteTitle')"
:submitText="$tr('deleteChannel')"
:title="canEdit ? $tr('deleteTitle') : $tr('removeTitle')"
:submitText="canEdit ? $tr('deleteChannel') : $tr('removeBtn')"
:cancelText="$tr('cancel')"
data-test="delete-modal"
@submit="handleDelete"
@cancel="deleteDialog = false"
>
{{ $tr('deletePrompt') }}
{{ canEdit ? $tr('deletePrompt') : $tr('removePrompt') }}
</KModal>
<!-- Copy dialog -->
<ChannelTokenModal
Expand Down Expand Up @@ -343,13 +345,21 @@
}
},
methods: {
...mapActions('channel', ['deleteChannel']),
...mapActions('channel', ['deleteChannel', 'removeViewer']),
...mapMutations('channel', { updateChannel: 'UPDATE_CHANNEL' }),
handleDelete() {
this.deleteChannel(this.channelId).then(() => {
this.deleteDialog = false;
this.$store.dispatch('showSnackbarSimple', this.$tr('channelDeletedSnackbar'));
});
if (!this.canEdit) {
const currentUserId = this.$store.state.session.currentUser.id;
Comment thread
Abhishek-Punhani marked this conversation as resolved.
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
Expand All @@ -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',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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'
);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
28 changes: 28 additions & 0 deletions contentcuration/contentcuration/viewsets/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down