diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue index 446c3896d2..a8d8f0c456 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/publish/PublishModal.vue @@ -49,7 +49,7 @@ :label="$tr('versionDescriptionLabel')" :invalid="!isDescriptionValid" :invalidText="$tr('descriptionRequiredMessage')" - :showInvalidText="showInvalidText" + :showInvalidText="showDescriptionInvalidText" autofocus textArea /> @@ -57,6 +57,20 @@ + + + + + + + @@ -71,6 +85,7 @@ import { mapActions, mapGetters } from 'vuex'; import HelpTooltip from 'shared/views/HelpTooltip'; import { forceServerSync } from 'shared/data/serverSync'; + import { LanguagesList } from 'shared/leUtils/Languages'; export default { name: 'PublishModal', @@ -88,9 +103,13 @@ step: 0, publishDescription: '', size: 0, - showInvalidText: false, // lazy validation + showDescriptionInvalidText: false, // lazy validation + showLanguageInvalidText: false, loading: false, loadingTaskId: null, + language: {}, + channelLanguages: [], + channelLanguageExists: true, }; }, computed: { @@ -112,9 +131,34 @@ isDescriptionValid() { return this.publishDescription && this.publishDescription.trim(); }, + isLanguageValid() { + return Object.keys(this.language).length > 0; + }, sizeCalculationTask() { return this.loadingTaskId ? this.getAsyncTask(this.loadingTaskId) : null; }, + isCheffedChannel() { + return Boolean(this.currentChannel.ricecooker_version); + }, + isPrivateChannel() { + return this.currentChannel.public; + }, + isFirstPublish() { + return this.currentChannel.version === 0; + }, + defaultLanguage() { + const channelLang = this.filterLanguages(l => l.id === this.currentChannel.language)[0]; + return this.languages.some(lang => lang.value === channelLang.value) ? channelLang : {}; + }, + showLanguageDropdown() { + return ( + ((this.isCheffedChannel || this.isPrivateChannel) && this.isFirstPublish) || + !this.channelLanguageExists + ); + }, + languages() { + return this.filterLanguages(l => this.channelLanguages.includes(l.id)); + }, }, watch: { sizeCalculationTask(task) { @@ -139,16 +183,35 @@ // this.loading = response.stale; // this.loadingTaskId = response.changes.length ? response.changes[0].key : null; // }); + this.channelLanguageExistsInResources().then(exists => { + this.channelLanguageExists = exists; + if (!exists) { + this.getLanguagesInChannelResources().then(languages => { + this.channelLanguages = languages.length ? languages : [this.currentChannel.language]; + this.language = this.defaultLanguage; + }); + } else { + this.channelLanguages = [this.currentChannel.language]; + this.language = this.defaultLanguage; + } + }); }, methods: { - ...mapActions('currentChannel', ['publishChannel']), + ...mapActions('channel', ['updateChannel']), + ...mapActions('currentChannel', [ + 'publishChannel', + 'channelLanguageExistsInResources', + 'getLanguagesInChannelResources', + ]), close() { this.publishDescription = ''; + this.language = this.defaultLanguage; this.dialog = false; }, validate() { - this.showInvalidText = true; - return this.isDescriptionValid; + this.showDescriptionInvalidText = !this.isDescriptionValid; + this.showLanguageInvalidText = !this.isLanguageValid; + return !this.showDescriptionInvalidText && !this.showLanguageInvalidText; }, async handlePublish() { if (this.validate()) { @@ -156,9 +219,20 @@ await forceServerSync(); } - this.publishChannel(this.publishDescription).then(this.close); + this.updateChannel({ + id: this.currentChannel.id, + language: this.language.value, + }).then(() => { + this.publishChannel(this.publishDescription).then(this.close); + }); } }, + filterLanguages(filterFn) { + return LanguagesList.filter(filterFn).map(l => ({ + value: l.id, + label: l.native_name, + })); + }, }, $trs: { // Incomplete channel window @@ -174,8 +248,11 @@ descriptionRequiredMessage: "Please describe what's new in this version before publishing", descriptionDescriptionTooltip: 'This description will be shown to Kolibri admins before they update channel versions', + languageDescriptionTooltip: 'The default language for a channel and its resources', cancelButton: 'Cancel', publishButton: 'Publish', + languageLabel: 'Language', + languageRequiredMessage: 'Please select a language for this channel', }, }; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/publish/__tests__/publishModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/publish/__tests__/publishModal.spec.js index 3027fae7a3..1de950ceec 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/publish/__tests__/publishModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/publish/__tests__/publishModal.spec.js @@ -85,17 +85,21 @@ describe('publishModal', () => { }); }); describe('on publish step', () => { + const updateChannel = jest.fn(); const publishChannel = jest.fn(); + beforeEach(() => { wrapper.setData({ step: steps.PUBLISH }); wrapper.setMethods({ - publishChannel: () => { + updateChannel: () => { return new Promise(resolve => { + updateChannel(); publishChannel(); resolve(); }); }, }); + updateChannel.mockReset(); publishChannel.mockReset(); }); it('publish button should trigger form validation', () => { @@ -110,20 +114,26 @@ describe('publishModal', () => { "Please describe what's new in this version before publishing" ); }); - it('publishing should be blocked if no description is given', () => { + it('publishing should be blocked if no description & language are given', () => { wrapper .find('[data-test="confirm-publish-modal"]') .find('form') .trigger('submit'); + expect(updateChannel).not.toHaveBeenCalled(); expect(publishChannel).not.toHaveBeenCalled(); }); - it('publish button should call publishChannel if description is given', () => { + it('publish button should call updateChannel if description and language are given', () => { const description = 'Version notes'; - wrapper.setData({ publishDescription: description }); + const language = { + value: 'en', + label: 'English', + }; + wrapper.setData({ publishDescription: description, language }); wrapper .find('[data-test="confirm-publish-modal"]') .find('form') .trigger('submit'); + expect(updateChannel).toHaveBeenCalled(); expect(publishChannel).toHaveBeenCalled(); }); it('cancel button on publish step should also close modal', () => { diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/__tests__/module.spec.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/__tests__/module.spec.js index f43043649a..cbad4da235 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/__tests__/module.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/__tests__/module.spec.js @@ -31,5 +31,23 @@ describe('currentChannel store', () => { spy.mockRestore(); }); }); + it('channelLanguageExistsInResources action should call `language_exists` endpoint', () => { + const spy = jest + .spyOn(Channel, 'languageExistsInResources') + .mockImplementation(() => Promise.resolve()); + return store.dispatch('currentChannel/channelLanguageExistsInResources').then(() => { + expect(spy.mock.calls[0][0]).toBe(store.state.currentChannel.currentChannelId); + spy.mockRestore(); + }); + }); + it('getLanguagesInChannelResources action should call `languages` endpoint', () => { + const spy = jest + .spyOn(Channel, 'languagesInResources') + .mockImplementation(() => Promise.resolve()); + return store.dispatch('currentChannel/getLanguagesInChannelResources').then(() => { + expect(spy.mock.calls[0][0]).toBe(store.state.currentChannel.currentChannelId); + spy.mockRestore(); + }); + }); }); }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/actions.js index 9dca09a0a2..d640b905af 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/actions.js @@ -54,3 +54,13 @@ export function deployCurrentChannel(context) { export function publishChannel(context, version_notes) { return Channel.publish(context.state.currentChannelId, version_notes); } + +export function channelLanguageExistsInResources(context) { + const channelId = context.state.currentChannelId; + return Channel.languageExistsInResources(channelId); +} + +export function getLanguagesInChannelResources(context) { + const channelId = context.state.currentChannelId; + return Channel.languagesInResources(channelId); +} diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 1c684ee07a..f89845dd18 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -7,6 +7,7 @@ import isString from 'lodash/isString'; import matches from 'lodash/matches'; import overEvery from 'lodash/overEvery'; import sortBy from 'lodash/sortBy'; +import compact from 'lodash/compact'; import uniq from 'lodash/uniq'; import uniqBy from 'lodash/uniqBy'; @@ -1271,6 +1272,44 @@ export const Channel = new CreateModelResource({ // For channels, the appropriate channel_id for a change is just the key return obj.id; }, + async languageExistsInResources(id) { + let langExists = await this.transaction( + { mode: 'r' }, + TABLE_NAMES.CHANNEL, + TABLE_NAMES.CONTENTNODE, + () => { + return Channel.table.get(id).then(async channel => { + return ( + (await ContentNode.table + .where({ + channel_id: id, + language: channel.language, + }) + .count()) > 0 + ); + }); + } + ); + if (!langExists) { + langExists = await client + .get(this.getUrlFunction('language_exists')(id)) + .then(response => response.data.exists); + } + return langExists; + }, + async languagesInResources(id) { + const localLanguages = await this.transaction({ mode: 'r' }, TABLE_NAMES.CONTENTNODE, () => { + return ContentNode.table + .where({ channel_id: id }) + .filter(node => node.language !== null) + .toArray() + .then(nodes => nodes.map(node => node.language)); + }); + const remoteLanguages = await client + .get(this.getUrlFunction('languages')(id)) + .then(response => response.data.languages); + return uniq(compact(localLanguages.concat(remoteLanguages))); + }, }); function getChannelFromChannelScope() { diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 271367e58f..17549ab128 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -6,11 +6,13 @@ from django.db.models import Exists from django.db.models import OuterRef from django.urls import reverse +from kolibri_public.models import ContentNode as PublicContentNode from le_utils.constants import content_kinds from contentcuration import models from contentcuration import models as cc from contentcuration.constants import channel_history +from contentcuration.models import ContentNode from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import generate_create_event @@ -614,3 +616,93 @@ def test_unpublished_changes_query_no_publishable_since_publish_if_publish_fails unpublished_changes = _unpublished_changes_query(outer_ref) channels = models.Channel.objects.filter(pk=channel.pk).annotate(unpublished_changes=Exists(unpublished_changes)) self.assertFalse(channels[0].unpublished_changes) + + +class ChannelLanguageTestCase(StudioAPITestCase): + + def setUp(self): + super(ChannelLanguageTestCase, self).setUp() + self.channel = testdata.channel() + self.channel.language_id = 'en' + self.channel.save() + + self.channel_id = self.channel.id + self.node_id = '00000000000000000000000000000003' + self.public_node = PublicContentNode.objects.create( + id=uuid.UUID(self.node_id), + title='Video 1', + content_id=uuid.uuid4(), + channel_id=uuid.UUID(self.channel.id), + lang_id='en', + ) + + def test_channel_language_exists_valid_channel(self): + + ContentNode.objects.filter(node_id=self.public_node.id).update(language_id='en') + response = self._perform_action("channel-language-exists", self.channel.id) + self.assertEqual(response.status_code, 200, response.content) + self.assertTrue(response.json()["exists"]) + + def test_channel_language_doesnt_exists_valid_channel(self): + + PublicContentNode.objects.filter(id=self.public_node.id).update(lang_id='es') + response = self._perform_action("channel-language-exists", self.channel.id) + self.assertEqual(response.status_code, 200, response.content) + self.assertFalse(response.json()["exists"]) + + def test_channel_language_exists_invalid_channel(self): + + response = self._perform_action("channel-language-exists", 'unknown_channel_id') + self.assertEqual(response.status_code, 404, response.content) + + def test_channel_language_exists_invalid_request(self): + + response = self._perform_action("channel-language-exists", None) + self.assertEqual(response.status_code, 404, response.content) + + def test_get_languages_in_channel_success_languages(self): + new_language = 'swa' + self.channel.language_id = new_language + self.channel.save() + PublicContentNode.objects.filter(id=self.public_node.id).update(lang_id=new_language) + ContentNode.objects.filter(node_id=self.public_node.id).update(language_id=new_language) + + response = self._perform_action("channel-languages", self.channel.id) + languages = response.json()["languages"] + + self.assertEqual(response.status_code, 200, response.content) + self.assertListEqual(languages, [new_language]) + + def test_get_languages_in_channel_success_channel_language_excluded(self): + new_language = 'fr' + channel_lang = 'en' + self.channel.language_id = channel_lang + self.channel.save() + PublicContentNode.objects.filter(id=self.public_node.id).update(lang_id=new_language) + ContentNode.objects.filter(node_id=self.public_node.id).update(language_id=new_language) + + response = self._perform_action("channel-languages", self.channel.id) + languages = response.json()["languages"] + + self.assertEqual(response.status_code, 200, response.content) + self.assertListEqual(languages, [new_language]) + self.assertFalse(channel_lang in languages) + + def test_get_languages_in_channel_success_no_languages(self): + + response = self._perform_action("channel-languages", self.channel.id) + languages = response.json()["languages"] + + self.assertEqual(response.status_code, 200, response.content) + self.assertListEqual(languages, []) + + def test_get_languages_in_channel_invalid_request(self): + + response = self._perform_action("channel-languages", None) + self.assertEqual(response.status_code, 404, response.content) + + def _perform_action(self, url_path, channel_id): + user = testdata.user() + self.client.force_authenticate(user=user) + response = self.client.get(reverse(url_path, kwargs={"pk": channel_id}), format="json") + return response diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index ee06244836..796f7999e8 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -1,23 +1,35 @@ import logging +import uuid from functools import reduce from operator import or_ +from typing import Dict +from typing import List +from typing import Union from django.conf import settings from django.db import IntegrityError from django.db.models import Exists +from django.db.models import F from django.db.models import OuterRef from django.db.models import Q from django.db.models import Subquery +from django.db.models import UUIDField from django.db.models import Value +from django.db.models.functions import Cast from django.db.models.functions import Coalesce +from django.http import HttpResponse +from django.http import HttpResponseNotFound +from django.http import JsonResponse from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page from django_cte import With from django_filters.rest_framework import BooleanFilter from django_filters.rest_framework import CharFilter +from kolibri_public.models import ContentNode as PublicContentNode from le_utils.constants import content_kinds from le_utils.constants import roles from rest_framework import serializers +from rest_framework.decorators import action from rest_framework.exceptions import ValidationError from rest_framework.permissions import AllowAny from rest_framework.permissions import IsAuthenticated @@ -85,7 +97,6 @@ class CatalogListPagination(CachedListPagination): .order_by("-token")[:1] ) - base_channel_filter_fields = ( "keywords", "published", @@ -135,9 +146,11 @@ def filter_keywords(self, queryset, name, value): dash_replaced_search_query = get_fts_search_query(value.replace("-", "")) channel_keywords_query = (Exists(ChannelFullTextSearch.objects.filter( - Q(keywords_tsvector=search_query) | Q(keywords_tsvector=dash_replaced_search_query), channel_id=OuterRef("id")))) + Q(keywords_tsvector=search_query) | Q(keywords_tsvector=dash_replaced_search_query), + channel_id=OuterRef("id")))) contentnode_search_query = (Exists(ContentNodeFullTextSearch.objects.filter( - Q(keywords_tsvector=search_query) | Q(author_tsvector=search_query), channel_id=OuterRef("id")))) + Q(keywords_tsvector=search_query) | Q(author_tsvector=search_query), + channel_id=OuterRef("id")))) return queryset.filter(Q(channel_keywords_query) | Q(contentnode_search_query)) @@ -427,7 +440,9 @@ def create(self, request, *args, **kwargs): except IntegrityError as e: return Response({"error": str(e)}, status=409) instance = serializer.instance - Change.create_change(generate_create_event(instance.id, CHANNEL, request.data, channel_id=instance.id), applied=True, created_by_id=request.user.id) + Change.create_change( + generate_create_event(instance.id, CHANNEL, request.data, channel_id=instance.id), + applied=True, created_by_id=request.user.id) return Response(self.serialize_object(pk=instance.pk), status=HTTP_201_CREATED) def destroy(self, request, *args, **kwargs): @@ -480,7 +495,8 @@ def annotate_queryset(self, queryset): count=SQCount(non_topic_content_ids, field="content_id"), ) - queryset = queryset.annotate(unpublished_changes=Exists(_unpublished_changes_query(OuterRef("id")))) + queryset = queryset.annotate( + unpublished_changes=Exists(_unpublished_changes_query(OuterRef("id")))) return queryset @@ -490,7 +506,8 @@ def publish_from_changes(self, changes): # Publish change will have key, version_notes, and language. try: self.publish( - publish["key"], version_notes=publish.get("version_notes"), language=publish.get("language") + publish["key"], version_notes=publish.get("version_notes"), + language=publish.get("language") ) except Exception as e: log_sync_exception(e, user=self.request.user, change=publish) @@ -510,7 +527,8 @@ def publish(self, pk, version_notes="", language=None): channel.mark_publishing(self.request.user) - with create_change_tracker(pk, CHANNEL, channel.id, self.request.user, "export-channel") as progress_tracker: + with create_change_tracker(pk, CHANNEL, channel.id, self.request.user, + "export-channel") as progress_tracker: try: channel = publish_channel( self.request.user.pk, @@ -541,7 +559,8 @@ def publish(self, pk, version_notes="", language=None): except Exception: Change.create_changes([ generate_update_event( - channel.id, CHANNEL, {"publishing": False, "unpublished_changes": True}, channel_id=channel.id + channel.id, CHANNEL, {"publishing": False, "unpublished_changes": True}, + channel_id=channel.id ), ], applied=True, unpublishable=True) raise @@ -564,7 +583,8 @@ def sync_from_changes(self, changes): errors.append(sync) return errors - def sync(self, pk, titles_and_descriptions=False, resource_details=False, files=False, assessment_items=False): + def sync(self, pk, titles_and_descriptions=False, resource_details=False, files=False, + assessment_items=False): logging.debug("Entering the sync channel endpoint") channel = self.get_edit_queryset().get(pk=pk) @@ -574,18 +594,19 @@ def sync(self, pk, titles_and_descriptions=False, resource_details=False, files= if ( not channel.main_tree.get_descendants() - .filter( + .filter( Q(original_node__isnull=False) | Q( original_channel_id__isnull=False, original_source_node_id__isnull=False, ) ) - .exists() + .exists() ): raise ValidationError("Cannot sync a channel with no imported content") - with create_change_tracker(pk, CHANNEL, channel.id, self.request.user, "sync-channel") as progress_tracker: + with create_change_tracker(pk, CHANNEL, channel.id, self.request.user, + "sync-channel") as progress_tracker: sync_channel( channel, titles_and_descriptions, @@ -641,6 +662,108 @@ def deploy(self, user, pk): channel_id=channel.id, ), applied=True, created_by_id=user.id) + @action(detail=True, methods=["get"], url_path='language_exists', url_name='language-exists') + def channel_language_exists(self, request, pk=None) -> Union[JsonResponse, HttpResponse]: + """ + Verify that the language set for a channel is present in at least one of its resources. + + :param request: The request object + :param pk: The ID of the channel + :return: JsonResponse with exists=True if the language exists, False otherwise + :rtype: JsonResponse + """ + if not self._channel_exists(pk): + return HttpResponseNotFound("No channel matching: {}".format(pk)) + + channel_lang, main_tree_id = self._get_channel_details(pk).values() + langs_in_channel = self._get_channel_content_languages(pk, main_tree_id) + lang_exists = channel_lang in langs_in_channel + + return JsonResponse({"exists": lang_exists}) + + @action(detail=True, methods=["get"], url_path='languages', url_name='languages') + def get_languages_in_channel(self, request, pk=None) -> Union[JsonResponse, HttpResponse]: + """ + Get all the languages present in a channel's resources. + + :param request: The request object + :param pk: The ID of the channel + :return: JsonResponse with a list of languages present in the channel + :rtype: JsonResponse + """ + if not self._channel_exists(pk): + return HttpResponseNotFound("No channel matching: {}".format(pk)) + + channel_details = self._get_channel_details(pk) + main_tree_id = channel_details.get("main_tree_id") + langs_in_content = self._get_channel_content_languages(pk, main_tree_id) + return JsonResponse({"languages": langs_in_content}) + + def _channel_exists(self, channel_id) -> bool: + """ + Check if a channel exists. + + :param channel_id: The ID of the channel + :return: True if the channel exists, False otherwise + :rtype: bool + """ + try: + return Channel.objects.filter(pk=channel_id).exists() + except Exception as e: + logging.error(f"Error checking if channel exists: {e}") + return False + + def _get_channel_details(self, channel_id) -> Dict[str, any]: + """ + Get the language set for a channel. + + :param channel_id: The ID of the channel + :return: The language code set for the channel + :rtype: str + """ + try: + channel_details = (Channel.objects.filter(pk=channel_id) + .values("language_id", "main_tree_id").first()) + except Channel.DoesNotExist as e: + logging.error(str(e)) + channel_details = None + + if not channel_details: + channel_details = dict(language_id=None, main_tree_id=None) + + return channel_details + + def _get_channel_content_languages(self, channel_id, main_tree_id=None) -> List[str]: + """ + Get all the languages used in a channel's resources. + + :param channel_id: The ID of the channel + :return: A list of language codes used in the channel + :rtype: List[str] + """ + if not channel_id: + return [] + + try: + ids_to_exclude = [main_tree_id] if main_tree_id else [] + node_ids_subquery = PublicContentNode.objects.filter( + channel_id=uuid.UUID(channel_id), + ).values_list("id", flat=True) + lang_ids = ContentNode.objects.filter( + language_id__isnull=False + ).annotate( + cast_node_id=Cast(F('node_id'), output_field=UUIDField()) + ).filter( + cast_node_id__in=Subquery(node_ids_subquery) + ).exclude( + id__in=ids_to_exclude + ).values_list('language_id', flat=True).distinct() + unique_lang_ids = list(set(lang_ids)) + except Exception as e: + logging.error(str(e)) + unique_lang_ids = [] + return unique_lang_ids + @method_decorator( cache_page( @@ -710,7 +833,7 @@ def filter_keywords(self, queryset, name, value): editors_first_name = reduce(or_, (Q(editors__first_name__icontains=k) for k in keywords)) editors_last_name = reduce(or_, (Q(editors__last_name__icontains=k) for k in keywords)) editors_email = reduce(or_, (Q(editors__email__icontains=k) for k in keywords)) - return queryset.annotate(primary_token=primary_token_subquery,).filter( + return queryset.annotate(primary_token=primary_token_subquery, ).filter( Q(name__icontains=value) | Q(pk__istartswith=value) | Q(primary_token=value.replace("-", "")) @@ -727,6 +850,7 @@ class AdminChannelSerializer(ChannelSerializer): This is a write only serializer - we leverage it to do create and update operations, but read operations are handled by the Viewset. """ + class Meta: model = Channel fields = ( @@ -785,7 +909,9 @@ def update(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) self.perform_update(serializer) - Change.create_change(generate_update_event(instance.id, CHANNEL, request.data, channel_id=instance.id), applied=True, created_by_id=request.user.id) + Change.create_change( + generate_update_event(instance.id, CHANNEL, request.data, channel_id=instance.id), + applied=True, created_by_id=request.user.id) return Response(self.serialize_object())