From 0e3345989b9095898567734488e58cbfef8b320d Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 23 May 2023 16:51:29 +0530 Subject: [PATCH 001/257] created captionviewset --- .../migrations/0143_generatedcaptions.py | 21 +++++++++++++++ contentcuration/contentcuration/models.py | 6 +++++ contentcuration/contentcuration/urls.py | 2 ++ .../contentcuration/viewsets/captions.py | 27 +++++++++++++++++++ .../contentcuration/viewsets/sync/base.py | 3 +++ .../viewsets/sync/constants.py | 2 ++ 6 files changed, 61 insertions(+) create mode 100644 contentcuration/contentcuration/migrations/0143_generatedcaptions.py create mode 100644 contentcuration/contentcuration/viewsets/captions.py diff --git a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py b/contentcuration/contentcuration/migrations/0143_generatedcaptions.py new file mode 100644 index 0000000000..0502d7e8bc --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_generatedcaptions.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.14 on 2023-05-23 11:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='GeneratedCaptions', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('generated_captions', models.JSONField()), + ('language', models.CharField(max_length=10)), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index e17be830fc..6e89b631db 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2057,6 +2057,12 @@ def __str__(self): return self.ietf_name() +class GeneratedCaptions(models.Model): + id = models.AutoField(primary_key=True) + generated_captions = models.JSONField() + language = models.CharField(max_length=10) + + ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index bb03f3876e..ad3bb991c4 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,6 +32,7 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet +from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.channel import AdminChannelViewSet from contentcuration.viewsets.channel import CatalogViewSet @@ -55,6 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") +router.register(r'captions', CaptionViewSet) router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py new file mode 100644 index 0000000000..84a5e3981c --- /dev/null +++ b/contentcuration/contentcuration/viewsets/captions.py @@ -0,0 +1,27 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from contentcuration.models import GeneratedCaptions + + +class GeneratedCaptionsSerializer(serializers.ModelSerializer): + class Meta: + model = GeneratedCaptions + fields = ['id', 'generated_captions', 'language'] + +class CaptionViewSet(ModelViewSet): + queryset = GeneratedCaptions.objects.all() + serializer_class = GeneratedCaptionsSerializer + + def create(self, request): + # handles the creation operation and return serialized data + pass + + def update(self, request): + # handles the updating of an existing `GeneratedCaption` instance. + pass + + def destroy(self, request): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index f11a8f4729..44e3861050 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,6 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet +from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet @@ -14,6 +15,7 @@ from contentcuration.viewsets.invitation import InvitationViewSet from contentcuration.viewsets.sync.constants import ASSESSMENTITEM from contentcuration.viewsets.sync.constants import BOOKMARK +from contentcuration.viewsets.sync.constants import CAPTION from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import CHANNELSET from contentcuration.viewsets.sync.constants import CLIPBOARD @@ -73,6 +75,7 @@ def __init__(self, change_type, viewset_class): (EDITOR_M2M, ChannelUserViewSet), (VIEWER_M2M, ChannelUserViewSet), (SAVEDSEARCH, SavedSearchViewSet), + (CAPTION, CaptionViewSet), ] ) diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index 84c2b5aad7..ffc3227873 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,6 +22,7 @@ # Client-side table constants BOOKMARK = "bookmark" +CAPTION = "captions" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" @@ -39,6 +40,7 @@ ALL_TABLES = set( [ BOOKMARK, + CAPTION, CHANNEL, CLIPBOARD, CONTENTNODE, From d0aaf90be846779d49cc8311fcc6f32e7a2cff0e Mon Sep 17 00:00:00 2001 From: akash5100 Date: Mon, 19 Jun 2023 12:40:39 +0530 Subject: [PATCH 002/257] Adds captions modal with visibility controlled by featureflag --- .../CaptionsEditor/CaptionsEditor.vue | 19 +++++++++ .../channelEdit/components/edit/EditView.vue | 39 +++++++++++++++--- .../frontend/channelEdit/constants.js | 1 + .../vuex/currentChannel/getters.js | 4 ++ .../frontend/shared/data/constants.js | 1 + .../frontend/shared/data/resources.js | 4 ++ ...3_generatedcaptions.py => 0143_caption.py} | 10 +++-- contentcuration/contentcuration/models.py | 10 +++-- contentcuration/contentcuration/urls.py | 4 +- .../contentcuration/viewsets/caption.py | 40 +++++++++++++++++++ .../contentcuration/viewsets/captions.py | 27 ------------- .../contentcuration/viewsets/sync/base.py | 2 +- .../viewsets/sync/constants.py | 2 +- 13 files changed, 120 insertions(+), 43 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue rename contentcuration/contentcuration/migrations/{0143_generatedcaptions.py => 0143_caption.py} (53%) create mode 100644 contentcuration/contentcuration/viewsets/caption.py delete mode 100644 contentcuration/contentcuration/viewsets/captions.py diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue new file mode 100644 index 0000000000..33f25de838 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue index f3ce855b2e..5f221e9b72 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue @@ -62,6 +62,16 @@ {{ relatedResourcesCount }} + + + + {{ $tr(tabs.CAPTIONS) }} + @@ -82,6 +92,7 @@ + + + + + @@ -104,6 +120,7 @@ import { TabNames } from '../../constants'; import AssessmentTab from '../../components/AssessmentTab/AssessmentTab'; + import CaptionsEditor from '../../components/CaptionsEditor/CaptionsEditor' import RelatedResourcesTab from '../../components/RelatedResourcesTab/RelatedResourcesTab'; import DetailsTabView from './DetailsTabView'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -113,11 +130,12 @@ export default { name: 'EditView', components: { - DetailsTabView, - AssessmentTab, - RelatedResourcesTab, - Tabs, - ToolBar, + AssessmentTab, + CaptionsEditor, + DetailsTabView, + RelatedResourcesTab, + Tabs, + ToolBar, }, props: { nodeIds: { @@ -143,6 +161,7 @@ 'getImmediateRelatedResourcesCount', ]), ...mapGetters('assessmentItem', ['getAssessmentItemsAreValid', 'getAssessmentItemsCount']), + ...mapGetters('currentChannel', ['isAIFeatureEnabled']), firstNode() { return this.nodes.length ? this.nodes[0] : null; }, @@ -167,6 +186,14 @@ showRelatedResourcesTab() { return this.oneSelected && this.firstNode && this.firstNode.kind !== 'topic'; }, + showCaptions() { + return ( + this.oneSelected && + this.firstNode && + (this.firstNode.kind === 'video' || this.firstNode.kind === 'audio') && + this.isAIFeatureEnabled + ) + }, countText() { const totals = reduce( this.nodes, @@ -260,6 +287,8 @@ questions: 'Questions', /** @see TabNames.RELATED */ related: 'Related', + /** @see TabNames.CAPTIONS */ + captions: 'Captions', /* eslint-enable kolibri/vue-no-unused-translations */ noItemsToEditText: 'Please select resources or folders to edit', invalidFieldsToolTip: 'Some required information is missing', diff --git a/contentcuration/contentcuration/frontend/channelEdit/constants.js b/contentcuration/contentcuration/frontend/channelEdit/constants.js index f36ebc5630..46a5d2d411 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/constants.js +++ b/contentcuration/contentcuration/frontend/channelEdit/constants.js @@ -55,6 +55,7 @@ export const TabNames = { PREVIEW: 'preview', QUESTIONS: 'questions', RELATED: 'related', + CAPTIONS: 'captions' }; export const modes = { diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js index ead653c2e4..3aeefe506b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js @@ -14,6 +14,10 @@ export function canEdit(state, getters, rootState, rootGetters) { ); } +export function isAIFeatureEnabled(state, getters, rootState, rootGetters) { + return rootGetters.featureFlags.ai_feature || false; +} + // Allow some extra actions for ricecooker channels export function canManage(state, getters, rootState, rootGetters) { return getters.currentChannel && (getters.currentChannel.edit || rootGetters.isAdmin); diff --git a/contentcuration/contentcuration/frontend/shared/data/constants.js b/contentcuration/contentcuration/frontend/shared/data/constants.js index bbc35582a3..11512e9f6b 100644 --- a/contentcuration/contentcuration/frontend/shared/data/constants.js +++ b/contentcuration/contentcuration/frontend/shared/data/constants.js @@ -39,6 +39,7 @@ export const TABLE_NAMES = { TASK: 'task', CHANGES_TABLE, BOOKMARK: 'bookmark', + CAPTION: 'caption' }; export const APP_ID = 'KolibriStudio'; diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 7005946042..4e5750bbf3 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -998,6 +998,10 @@ export const Bookmark = new Resource({ }, }); +export const Caption = new Resource({ + // TODO +}) + export const Channel = new Resource({ tableName: TABLE_NAMES.CHANNEL, urlName: 'channel', diff --git a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py b/contentcuration/contentcuration/migrations/0143_caption.py similarity index 53% rename from contentcuration/contentcuration/migrations/0143_generatedcaptions.py rename to contentcuration/contentcuration/migrations/0143_caption.py index 0502d7e8bc..3d6e0e769c 100644 --- a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py +++ b/contentcuration/contentcuration/migrations/0143_caption.py @@ -1,6 +1,8 @@ -# Generated by Django 3.2.14 on 2023-05-23 11:00 +# Generated by Django 3.2.14 on 2023-06-15 06:13 +import contentcuration.models from django.db import migrations, models +import uuid class Migration(migrations.Migration): @@ -11,10 +13,10 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='GeneratedCaptions', + name='Caption', fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('generated_captions', models.JSONField()), + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('caption', models.JSONField()), ('language', models.CharField(max_length=10)), ], ), diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 6e89b631db..6dcee5b5b3 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2057,10 +2057,14 @@ def __str__(self): return self.ietf_name() -class GeneratedCaptions(models.Model): - id = models.AutoField(primary_key=True) - generated_captions = models.JSONField() +class Caption(models.Model): + """ + Model to store captions and support intermediary changes + """ + id = UUIDField(primary_key=True, default=uuid.uuid4) + caption = models.JSONField() language = models.CharField(max_length=10) + # file_id = models.CharField(unique=True, max_length=32) ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index ad3bb991c4..581505918f 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,8 +32,8 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet -from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet +from contentcuration.viewsets.caption import CaptionViewSet from contentcuration.viewsets.channel import AdminChannelViewSet from contentcuration.viewsets.channel import CatalogViewSet from contentcuration.viewsets.channel import ChannelViewSet @@ -56,7 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") -router.register(r'captions', CaptionViewSet) +router.register(r'caption', CaptionViewSet) router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py new file mode 100644 index 0000000000..2dd2062bc7 --- /dev/null +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -0,0 +1,40 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from contentcuration.models import Caption + + +class CaptionSerializer(serializers.ModelSerializer): + class Meta: + model = Caption + fields = ["id", "caption", "language"] + + +class CaptionViewSet(ModelViewSet): + queryset = Caption.objects.all() + serializer_class = CaptionSerializer + + def create(self, request): + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + self.perform_create(serializer=serializer) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, headers=headers, status=status.HTTP_201_CREATED + ) + + def update(self, request, pk=None): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + if not serializer.is_valid(raise_exception=True): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py deleted file mode 100644 index 84a5e3981c..0000000000 --- a/contentcuration/contentcuration/viewsets/captions.py +++ /dev/null @@ -1,27 +0,0 @@ -from rest_framework import serializers, status -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from contentcuration.models import GeneratedCaptions - - -class GeneratedCaptionsSerializer(serializers.ModelSerializer): - class Meta: - model = GeneratedCaptions - fields = ['id', 'generated_captions', 'language'] - -class CaptionViewSet(ModelViewSet): - queryset = GeneratedCaptions.objects.all() - serializer_class = GeneratedCaptionsSerializer - - def create(self, request): - # handles the creation operation and return serialized data - pass - - def update(self, request): - # handles the updating of an existing `GeneratedCaption` instance. - pass - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index 44e3861050..879c67e123 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,7 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet -from contentcuration.viewsets.captions import CaptionViewSet +from contentcuration.viewsets.caption import CaptionViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index ffc3227873..1576953b50 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,7 +22,7 @@ # Client-side table constants BOOKMARK = "bookmark" -CAPTION = "captions" +CAPTION = "caption" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" From 8612976c5bc7e1d3fffc617e9f4e6a4812060230 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:29:13 +0530 Subject: [PATCH 003/257] Adds Sync API tests for CaptionFile ViewSet --- .../constants/transcription_languages.py | 106 +++++++++++++ .../migrations/0143_caption.py | 23 --- .../migrations/0143_captioncue_captionfile.py | 37 +++++ contentcuration/contentcuration/models.py | 41 ++++- .../tests/viewsets/test_caption.py | 147 ++++++++++++++++++ .../contentcuration/viewsets/caption.py | 99 ++++++++---- .../contentcuration/viewsets/sync/base.py | 8 +- .../viewsets/sync/constants.py | 6 +- 8 files changed, 403 insertions(+), 64 deletions(-) create mode 100644 contentcuration/contentcuration/constants/transcription_languages.py delete mode 100644 contentcuration/contentcuration/migrations/0143_caption.py create mode 100644 contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py create mode 100644 contentcuration/contentcuration/tests/viewsets/test_caption.py diff --git a/contentcuration/contentcuration/constants/transcription_languages.py b/contentcuration/contentcuration/constants/transcription_languages.py new file mode 100644 index 0000000000..753c91e7d3 --- /dev/null +++ b/contentcuration/contentcuration/constants/transcription_languages.py @@ -0,0 +1,106 @@ +# This file contains a list of transcription languages. +# The list is in the format of (language code, language name). +# For example, the first element in the list is ('en', 'english'). + + +CAPTIONS_LANGUAGES = [ + ("en", "english"), + ("zh", "chinese"), + ("de", "german"), + ("es", "spanish"), + ("ru", "russian"), + ("ko", "korean"), + ("fr", "french"), + ("ja", "japanese"), + ("pt", "portuguese"), + ("tr", "turkish"), + ("pl", "polish"), + ("ca", "catalan"), + ("nl", "dutch"), + ("ar", "arabic"), + ("sv", "swedish"), + ("it", "italian"), + ("id", "indonesian"), + ("hi", "hindi"), + ("fi", "finnish"), + ("vi", "vietnamese"), + ("he", "hebrew"), + ("uk", "ukrainian"), + ("el", "greek"), + ("ms", "malay"), + ("cs", "czech"), + ("ro", "romanian"), + ("da", "danish"), + ("hu", "hungarian"), + ("ta", "tamil"), + ("no", "norwegian"), + ("th", "thai"), + ("ur", "urdu"), + ("hr", "croatian"), + ("bg", "bulgarian"), + ("lt", "lithuanian"), + ("la", "latin"), + ("mi", "maori"), + ("ml", "malayalam"), + ("cy", "welsh"), + ("sk", "slovak"), + ("te", "telugu"), + ("fa", "persian"), + ("lv", "latvian"), + ("bn", "bengali"), + ("sr", "serbian"), + ("az", "azerbaijani"), + ("sl", "slovenian"), + ("kn", "kannada"), + ("et", "estonian"), + ("mk", "macedonian"), + ("br", "breton"), + ("eu", "basque"), + ("is", "icelandic"), + ("hy", "armenian"), + ("ne", "nepali"), + ("mn", "mongolian"), + ("bs", "bosnian"), + ("kk", "kazakh"), + ("sq", "albanian"), + ("sw", "swahili"), + ("gl", "galician"), + ("mr", "marathi"), + ("pa", "punjabi"), + ("si", "sinhala"), + ("km", "khmer"), + ("sn", "shona"), + ("yo", "yoruba"), + ("so", "somali"), + ("af", "afrikaans"), + ("oc", "occitan"), + ("ka", "georgian"), + ("be", "belarusian"), + ("tg", "tajik"), + ("sd", "sindhi"), + ("gu", "gujarati"), + ("am", "amharic"), + ("yi", "yiddish"), + ("lo", "lao"), + ("uz", "uzbek"), + ("fo", "faroese"), + ("ht", "haitian creole"), + ("ps", "pashto"), + ("tk", "turkmen"), + ("nn", "nynorsk"), + ("mt", "maltese"), + ("sa", "sanskrit"), + ("lb", "luxembourgish"), + ("my", "myanmar"), + ("bo", "tibetan"), + ("tl", "tagalog"), + ("mg", "malagasy"), + ("as", "assamese"), + ("tt", "tatar"), + ("haw", "hawaiian"), + ("ln", "lingala"), + ("ha", "hausa"), + ("ba", "bashkir"), + ("jw", "javanese"), + ("su", "sundanese"), +] \ No newline at end of file diff --git a/contentcuration/contentcuration/migrations/0143_caption.py b/contentcuration/contentcuration/migrations/0143_caption.py deleted file mode 100644 index 3d6e0e769c..0000000000 --- a/contentcuration/contentcuration/migrations/0143_caption.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-15 06:13 - -import contentcuration.models -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='Caption', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('caption', models.JSONField()), - ('language', models.CharField(max_length=10)), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py new file mode 100644 index 0000000000..b8176d6a02 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-06-26 17:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 6dcee5b5b3..577b9eaf4d 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,6 +2,7 @@ import json import logging import os +from typing import Any import urllib.parse import uuid from datetime import datetime @@ -68,6 +69,7 @@ from contentcuration.constants import channel_history from contentcuration.constants import completion_criteria from contentcuration.constants import user_history +from contentcuration.constants.transcription_languages import CAPTIONS_LANGUAGES from contentcuration.constants.contentnode import kind_activity_map from contentcuration.db.models.expressions import Array from contentcuration.db.models.functions import ArrayRemove @@ -2057,15 +2059,42 @@ def __str__(self): return self.ietf_name() -class Caption(models.Model): +class CaptionFile(models.Model): """ - Model to store captions and support intermediary changes + Represents a caption file record. + + - file_id: The identifier of related file in Google Cloud Storage. + - language: The language of the caption file. + """ + id = UUIDField(primary_key=True, default=uuid.uuid4) + file_id = UUIDField(default=uuid.uuid4, max_length=36) + language = models.CharField(choices=CAPTIONS_LANGUAGES, max_length=3) + + class Meta: + unique_together = ['file_id', 'language'] + + def __str__(self): + return "{file_id} -> {language}".format(file_id=self.file_id, language=self.language) + + +class CaptionCue(models.Model): + """ + Represents a caption cue in a VTT file. + + - text: The caption text. + - starttime: The start time of the cue in seconds. + - endtime: The end time of the cue in seconds. + - caption_file (Foreign Key): The related caption file. """ id = UUIDField(primary_key=True, default=uuid.uuid4) - caption = models.JSONField() - language = models.CharField(max_length=10) - # file_id = models.CharField(unique=True, max_length=32) - + text = models.TextField(null=False) + starttime = models.FloatField(null=False) + endtime = models.FloatField(null=False) + caption_file = models.ForeignKey(CaptionFile, related_name="caption_cue", on_delete=models.CASCADE) + + def __str__(self): + return "text: {text}, start_time: {starttime}, end_time: {endtime}".format(text=self.text, starttime=self.starttime, endtime=self.endtime) + ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py new file mode 100644 index 0000000000..07ffe9dfa0 --- /dev/null +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -0,0 +1,147 @@ +from __future__ import absolute_import + +import uuid + +from django.urls import reverse + +from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.tests.base import StudioAPITestCase +from contentcuration.tests import testdata +from contentcuration.tests.viewsets.base import SyncTestMixin +from contentcuration.tests.viewsets.base import generate_create_event +from contentcuration.tests.viewsets.base import generate_update_event +from contentcuration.tests.viewsets.base import generate_delete_event +from contentcuration.viewsets.sync.constants import CAPTION_FILE + +# class CRUDTestCase(StudioAPITestCase): + +class SyncTestCase(SyncTestMixin, StudioAPITestCase): + + @property + def caption_file_metadata(self): + return { + "file_id": uuid.uuid4().hex, + "language": "en", + } + + @property + def same_file_different_language_metadata(self): + id = uuid.uuid4().hex + return [ + { + "file_id": id, + "language": "en", + }, + { + "file_id": id, + "language": "ru", + } + ] + + @property + def caption_file_db_metadata(self): + return { + "file_id": uuid.uuid4().hex, + "language": "en", + } + + + def setUp(self): + super(SyncTestCase, self).setUp() + self.channel = testdata.channel() + self.user = testdata.user() + self.channel.editors.add(self.user) + + + def test_create_caption(self): + self.client.force_authenticate(user=self.user) + caption_file = self.caption_file_metadata + + response = self.sync_changes( + [ + generate_create_event( + uuid.uuid4().hex, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + + try: + caption_file_db = CaptionFile.objects.get( + file_id=caption_file["file_id"], + language=caption_file["language"], + ) + except CaptionFile.DoesNotExist: + self.fail("caption file was not created") + + # Check the values of the object in the PostgreSQL + self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) + self.assertEqual(caption_file_db.language, caption_file["language"]) + + def test_delete_caption_file(self): + self.client.force_authenticate(user=self.user) + caption_file = self.caption_file_metadata + pk = uuid.uuid4().hex + response = self.sync_changes( + [ + generate_create_event( + pk, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id + ) + ] + ) + self.assertEqual(response.status_code, 200, response.content) + + # Delete the caption file + response = self.sync_changes( + [ + generate_delete_event( + pk, + CAPTION_FILE, + channel_id=self.channel.id + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + + with self.assertRaises(CaptionFile.DoesNotExist): + caption_file_db = CaptionFile.objects.get( + file_id=caption_file['file_id'], + language=caption_file['language'] + ) + + + def test_delete_file_with_same_file_id_different_language(self): + self.client.force_authenticate(user=self.user) + obj = self.same_file_different_language_metadata + + caption_file_1 = CaptionFile.objects.create( + **obj[0] + ) + caption_file_2 = CaptionFile.objects.create( + **obj[1] + ) + + response = self.sync_changes( + [ + generate_delete_event( + caption_file_2.pk, + CAPTION_FILE, + channel_id=self.channel.id, + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + + with self.assertRaises(CaptionFile.DoesNotExist): + caption_file_db = CaptionFile.objects.get( + file_id=caption_file_2.file_id, + language=caption_file_2.language + ) diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 2dd2062bc7..1a62b191d5 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,40 +1,79 @@ from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from contentcuration.models import Caption +from contentcuration.models import CaptionCue +from contentcuration.models import CaptionFile +from contentcuration.viewsets.base import ValuesViewset + +from contentcuration.viewsets.sync.utils import log_sync_exception + +from django.core.exceptions import ObjectDoesNotExist + + +""" +[x] create file - POST /api/caption?file_id=..&language=.. +[x] delete file - DELETE /api/caption?file_id=..&language=.. + +[] create file cue - POST /api/caption/cue?file_id=..&language=.. +[] update file cue - PATCH /api/caption/cue?file_id=..&language=..&cue_id=.. +[] delete file cue - DELETE /api/caption/cue?file_id=..&language=..&cue_id=.. + +[] get the file cues - GET /api/caption?file_id=..&language=.. +""" + + +class CueSerializer(serializers.ModelSerializer): + class Meta: + model = CaptionCue + fields = ["text", "starttime", "endtime"] class CaptionSerializer(serializers.ModelSerializer): + caption_cue = CueSerializer(many=True, required=False) + class Meta: - model = Caption - fields = ["id", "caption", "language"] + model = CaptionFile + fields = ["file_id", "language", "caption_cue"] -class CaptionViewSet(ModelViewSet): - queryset = Caption.objects.all() +class CaptionViewSet(ValuesViewset): + # Handles operations for the CaptionFile model. + queryset = CaptionFile.objects.prefetch_related("caption_cue") + permission_classes = [IsAuthenticated] serializer_class = CaptionSerializer + values = ("file_id", "language", "caption_cue") + + field_map = {"file": "file_id", "language": "language"} + + def delete_from_changes(self, changes): + errors = [] + queryset = self.get_edit_queryset().order_by() + for change in changes: + try: + instance = queryset.filter(**dict(self.values_from_key(change["key"]))) + + self.perform_destroy(instance) + except ObjectDoesNotExist: + # If the object already doesn't exist, as far as the user is concerned + # job done! + pass + except Exception as e: + log_sync_exception(e, user=self.request.user, change=change) + change["errors"] = [str(e)] + errors.append(change) + return errors + + +class CaptionCueViewSet(ValuesViewset): + # Handles operations for the CaptionCue model. + queryset = CaptionCue.objects.all() + permission_classes = [IsAuthenticated] + serializer_class = CueSerializer + values = ("text", "starttime", "endtime") - def create(self, request): - serializer = self.get_serializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - self.perform_create(serializer=serializer) - headers = self.get_success_headers(serializer.data) - return Response( - serializer.data, headers=headers, status=status.HTTP_201_CREATED - ) - - def update(self, request, pk=None): - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=True) - if not serializer.is_valid(raise_exception=True): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) + field_map = { + "text": "text", + "start_time": "starttime", + "end_time": "endtime", + } + # Add caption file in field_map? diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index 879c67e123..7606853bcc 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,7 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet -from contentcuration.viewsets.caption import CaptionViewSet +from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet @@ -15,7 +15,8 @@ from contentcuration.viewsets.invitation import InvitationViewSet from contentcuration.viewsets.sync.constants import ASSESSMENTITEM from contentcuration.viewsets.sync.constants import BOOKMARK -from contentcuration.viewsets.sync.constants import CAPTION +from contentcuration.viewsets.sync.constants import CAPTION_CUES +from contentcuration.viewsets.sync.constants import CAPTION_FILE from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import CHANNELSET from contentcuration.viewsets.sync.constants import CLIPBOARD @@ -75,7 +76,8 @@ def __init__(self, change_type, viewset_class): (EDITOR_M2M, ChannelUserViewSet), (VIEWER_M2M, ChannelUserViewSet), (SAVEDSEARCH, SavedSearchViewSet), - (CAPTION, CaptionViewSet), + (CAPTION_FILE, CaptionViewSet), + (CAPTION_CUES, CaptionCueViewSet), ] ) diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index 1576953b50..6ad7305c6c 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,7 +22,8 @@ # Client-side table constants BOOKMARK = "bookmark" -CAPTION = "caption" +CAPTION_FILE = "caption_file" +CAPTION_CUES = "caption_cues" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" @@ -40,7 +41,8 @@ ALL_TABLES = set( [ BOOKMARK, - CAPTION, + CAPTION_FILE, + CAPTION_CUES, CHANNEL, CLIPBOARD, CONTENTNODE, From cc989c970f61db0f8b9f100c02fd1b5dfdceecca Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:33:46 +0530 Subject: [PATCH 004/257] Removes unnecessary imports --- contentcuration/contentcuration/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 577b9eaf4d..bc8f76a5e3 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,7 +2,6 @@ import json import logging import os -from typing import Any import urllib.parse import uuid from datetime import datetime From ebe810ab1be102149e29dd282b69ecb60908f2f9 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:35:36 +0530 Subject: [PATCH 005/257] Fixes text formatting --- .../tests/viewsets/test_caption.py | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 07ffe9dfa0..4e6eb45132 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -15,8 +15,8 @@ # class CRUDTestCase(StudioAPITestCase): -class SyncTestCase(SyncTestMixin, StudioAPITestCase): +class SyncTestCase(SyncTestMixin, StudioAPITestCase): @property def caption_file_metadata(self): return { @@ -35,7 +35,7 @@ def same_file_different_language_metadata(self): { "file_id": id, "language": "ru", - } + }, ] @property @@ -45,18 +45,16 @@ def caption_file_db_metadata(self): "language": "en", } - def setUp(self): super(SyncTestCase, self).setUp() self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata - + response = self.sync_changes( [ generate_create_event( @@ -88,10 +86,7 @@ def test_delete_caption_file(self): response = self.sync_changes( [ generate_create_event( - pk, - CAPTION_FILE, - caption_file, - channel_id=self.channel.id + pk, CAPTION_FILE, caption_file, channel_id=self.channel.id ) ] ) @@ -99,34 +94,22 @@ def test_delete_caption_file(self): # Delete the caption file response = self.sync_changes( - [ - generate_delete_event( - pk, - CAPTION_FILE, - channel_id=self.channel.id - ) - ] + [generate_delete_event(pk, CAPTION_FILE, channel_id=self.channel.id)] ) self.assertEqual(response.status_code, 200, response.content) with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file['file_id'], - language=caption_file['language'] + file_id=caption_file["file_id"], language=caption_file["language"] ) - def test_delete_file_with_same_file_id_different_language(self): self.client.force_authenticate(user=self.user) obj = self.same_file_different_language_metadata - caption_file_1 = CaptionFile.objects.create( - **obj[0] - ) - caption_file_2 = CaptionFile.objects.create( - **obj[1] - ) + caption_file_1 = CaptionFile.objects.create(**obj[0]) + caption_file_2 = CaptionFile.objects.create(**obj[1]) response = self.sync_changes( [ @@ -142,6 +125,5 @@ def test_delete_file_with_same_file_id_different_language(self): with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file_2.file_id, - language=caption_file_2.language + file_id=caption_file_2.file_id, language=caption_file_2.language ) From 8989ee3684b50553ef55030b9c832a599082067b Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:11:16 +0530 Subject: [PATCH 006/257] Creating CaptionCue with generate_create_event fails --- .../CaptionsEditor/CaptionsEditor.vue | 2 +- .../tests/viewsets/test_caption.py | 115 +++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 33f25de838..531dc7d90f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -16,4 +16,4 @@ \ No newline at end of file + diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 4e6eb45132..efd2bddc42 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -2,16 +2,17 @@ import uuid -from django.urls import reverse +import json from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.viewsets.caption import CaptionFileSerializer from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests import testdata from contentcuration.tests.viewsets.base import SyncTestMixin from contentcuration.tests.viewsets.base import generate_create_event from contentcuration.tests.viewsets.base import generate_update_event from contentcuration.tests.viewsets.base import generate_delete_event -from contentcuration.viewsets.sync.constants import CAPTION_FILE +from contentcuration.viewsets.sync.constants import CAPTION_FILE, CAPTION_CUES # class CRUDTestCase(StudioAPITestCase): @@ -45,12 +46,26 @@ def caption_file_db_metadata(self): "language": "en", } + @property + def caption_cue_metadata(self): + return { + "file": { + "file_id": uuid.uuid4().hex, + "language": "en", + }, + "cue": { + "text": "This is the beginning!", + "starttime": 0.0, + "endtime": 12.0, + }, + } + def setUp(self): super(SyncTestCase, self).setUp() self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - + # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata @@ -127,3 +142,97 @@ def test_delete_file_with_same_file_id_different_language(self): caption_file_db = CaptionFile.objects.get( file_id=caption_file_2.file_id, language=caption_file_2.language ) + + def test_caption_file_serialization(self): + metadata = self.caption_file_metadata + caption_file = CaptionFile.objects.create(**metadata) + serializer = CaptionFileSerializer(instance=caption_file) + try: + jd = json.dumps(serializer.data) # Try to serialize the data to JSON + except Exception as e: + self.fail(f"CaptionFile serialization failed. Error: {str(e)}") + + def test_caption_cue_serialization(self): + metadata = self.caption_cue_metadata + caption_file = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file, + }) + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + caption_cue_2 = CaptionCue.objects.create( + text='How are you?', + starttime=2.0, + endtime=3.0, + caption_file=caption_file + ) + serializer = CaptionFileSerializer(instance=caption_file) + try: + json.dumps(serializer.data) # Try to serialize the data to JSON + except Exception as e: + self.fail(f"CaptionFile serialization failed. Error: {str(e)}") + + def test_create_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + caption_file_1 = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file_1, + }) + # This works: caption_cue_1 = CaptionCue.objects.create(**caption_cue) + response = self.sync_changes( + [ + generate_create_event( + uuid.uuid4(), + CAPTION_CUES, + caption_cue, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + def test_delete_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + caption_file_1 = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file_1, + }) + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + # Delete the caption Cue that we just created + response = self.sync_changes( + [generate_delete_event(caption_cue_db.pk , CAPTION_CUES, channel_id=self.channel.id)] + ) + self.assertEqual(response.status_code, 200, response.content) + + caption_cue_db_exists = CaptionCue.objects.filter( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ).exists() + if caption_cue_db_exists: + self.fail("Caption Cue still exists!") + + def test_update_caption_cue(self): + pass From c8fea0e73ae792b242e8a62276d933efb8ceef17 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:46:25 +0530 Subject: [PATCH 007/257] Add failing test for CaptionFile JSON serialization --- contentcuration/contentcuration/urls.py | 2 +- .../contentcuration/viewsets/caption.py | 37 ++++++------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 581505918f..763db0f696 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -56,7 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") -router.register(r'caption', CaptionViewSet) +router.register(r'captions', CaptionViewSet, basename="captions") router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 1a62b191d5..55eb822976 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,35 +1,19 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.http import JsonResponse from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from contentcuration.models import CaptionCue -from contentcuration.models import CaptionFile +from contentcuration.models import CaptionCue, CaptionFile from contentcuration.viewsets.base import ValuesViewset - from contentcuration.viewsets.sync.utils import log_sync_exception -from django.core.exceptions import ObjectDoesNotExist - - -""" -[x] create file - POST /api/caption?file_id=..&language=.. -[x] delete file - DELETE /api/caption?file_id=..&language=.. - -[] create file cue - POST /api/caption/cue?file_id=..&language=.. -[] update file cue - PATCH /api/caption/cue?file_id=..&language=..&cue_id=.. -[] delete file cue - DELETE /api/caption/cue?file_id=..&language=..&cue_id=.. - -[] get the file cues - GET /api/caption?file_id=..&language=.. -""" - - -class CueSerializer(serializers.ModelSerializer): +class CaptionCueSerializer(serializers.ModelSerializer): class Meta: model = CaptionCue fields = ["text", "starttime", "endtime"] - -class CaptionSerializer(serializers.ModelSerializer): - caption_cue = CueSerializer(many=True, required=False) +class CaptionFileSerializer(serializers.ModelSerializer): + caption_cue = CaptionCueSerializer(many=True, required=False) class Meta: model = CaptionFile @@ -40,10 +24,13 @@ class CaptionViewSet(ValuesViewset): # Handles operations for the CaptionFile model. queryset = CaptionFile.objects.prefetch_related("caption_cue") permission_classes = [IsAuthenticated] - serializer_class = CaptionSerializer + serializer_class = CaptionFileSerializer values = ("file_id", "language", "caption_cue") - field_map = {"file": "file_id", "language": "language"} + field_map = { + "file": "file_id", + "language": "language" + } def delete_from_changes(self, changes): errors = [] @@ -68,7 +55,7 @@ class CaptionCueViewSet(ValuesViewset): # Handles operations for the CaptionCue model. queryset = CaptionCue.objects.all() permission_classes = [IsAuthenticated] - serializer_class = CueSerializer + serializer_class = CaptionCueSerializer values = ("text", "starttime", "endtime") field_map = { From de52608bad3fdae33daf3901787d8e58fabec88c Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 7 Jul 2023 13:12:04 +0530 Subject: [PATCH 008/257] Adds caption editor components, updated IndexedDB Resource --- .../CaptionsEditor/CaptionsEditor.vue | 15 +- .../frontend/channelEdit/constants.js | 2 +- .../channelEdit/vuex/caption/actions.js | 12 ++ .../channelEdit/vuex/caption/getters.js | 11 ++ .../channelEdit/vuex/caption/index.js | 26 +++ .../channelEdit/vuex/caption/mutations.js | 12 ++ .../frontend/shared/data/constants.js | 3 +- .../frontend/shared/data/resources.js | 17 +- contentcuration/contentcuration/models.py | 3 +- .../tests/viewsets/test_caption.py | 168 +++++++++++++----- contentcuration/contentcuration/urls.py | 3 +- .../contentcuration/viewsets/caption.py | 47 ++++- 12 files changed, 252 insertions(+), 67 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 531dc7d90f..4efd617226 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -1,17 +1,24 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue new file mode 100644 index 0000000000..ef7322e50d --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 4cbf88eadd..91401ed102 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -25,7 +25,6 @@ import { mapGetters, mapActions } from 'vuex'; import EditListItem from './EditListItem'; import Checkbox from 'shared/views/form/Checkbox'; - import { loadCaption } from '../../utils'; export default { name: 'EditList', @@ -44,16 +43,15 @@ }, }, watch: { - // watch the selected contentnode + // watch the selected contentnode to load captions value(newValue) { - if (this.isAIFeatureEnabled && newValue.length === 1) { - const nodeId = newValue[0]; - loadCaption([nodeId], this.loadCaptionFiles, this.loadCaptionCues); + if (newValue.length === 1) { + const nodeId = newValue[0]; // we dispatch action only when 1 node is selected + this.loadCaptions({ contentnode_id: nodeId }) } } }, computed: { - ...mapGetters('currentChannel', ['isAIFeatureEnabled']), selected: { get() { return this.value; @@ -77,7 +75,7 @@ }, }, methods: { - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), handleRemoved(nodeId) { const nodeIds = this.$route.params.detailNodeIds.split(',').filter(id => id !== nodeId); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 7547118227..09973325a5 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -197,7 +197,6 @@ import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; import { DELAYED_VALIDATION } from 'shared/constants'; - import { loadCaption } from '../../utils'; const CHECK_STORAGE_INTERVAL = 10000; @@ -251,7 +250,7 @@ computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), - ...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'isAIFeatureEnabled']), + ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), ...mapGetters('file', ['contentNodesAreUploading', 'getContentNodeFiles']), ...mapState({ online: state => state.connection.online, @@ -307,11 +306,6 @@ return this.nodeIds.filter(id => !this.getContentNodeIsValid(id)); }, }, - created() { - if (this.isAIFeatureEnabled && this.nodeIds.length === 1) { - loadCaption(this.nodeIds, this.loadCaptionFiles, this.loadCaptionCues); - } - }, beforeRouteEnter(to, from, next) { if ( to.name === RouteNames.CONTENTNODE_DETAILS || @@ -347,6 +341,11 @@ vm.loadFiles({ contentnode__in: childrenNodesIds }), vm.loadAssessmentItems({ contentnode__in: childrenNodesIds }), ]; + if(childrenNodesIds.length === 1) { + promises.push( + vm.loadCaptions({ contentnode_id: childrenNodesIds }) + ); + } } else { // no need to load assessment items or files as topics have none promises = [vm.loadContentNode(parentTopicId)]; @@ -400,7 +399,7 @@ ]), ...mapActions('file', ['loadFiles', 'updateFile']), ...mapActions('assessmentItem', ['loadAssessmentItems', 'updateAssessmentItems']), - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), ...mapMutations('contentNode', { enableValidation: 'ENABLE_VALIDATION_ON_NODES' }), closeModal() { this.promptUploading = false; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue index 5f221e9b72..f32b5d9709 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue @@ -103,7 +103,7 @@ - + @@ -120,7 +120,7 @@ import { TabNames } from '../../constants'; import AssessmentTab from '../../components/AssessmentTab/AssessmentTab'; - import CaptionsEditor from '../../components/CaptionsEditor/CaptionsEditor' + import CaptionsTab from '../../components/CaptionsTab/CaptionsTab' import RelatedResourcesTab from '../../components/RelatedResourcesTab/RelatedResourcesTab'; import DetailsTabView from './DetailsTabView'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -131,7 +131,7 @@ name: 'EditView', components: { AssessmentTab, - CaptionsEditor, + CaptionsTab, DetailsTabView, RelatedResourcesTab, Tabs, diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index ce425a1345..a475d4e67f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,4 +1,3 @@ -import store from './store'; import translator from './translator'; import { RouteNames } from './constants'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; @@ -295,28 +294,3 @@ export function getCompletionCriteriaLabels(node) { return labels; } - -/** - * Loads caption files and cues for a content node. - * - * Caption files and cues are only loaded when: - * - The AI feature flag is enabled - * - A single node is being edited - * - The node kind is 'video' or 'audio' - * - * @param {Array} nodeIds - Array containing node ID(s) being edited - * @param {Function} loadCaptionFiles - Vuex Action to load caption files - * @param {Function} loadCaptionCues - Vuex Action to load caption cues - */ -export function loadCaption(nodeIds, loadCaptionFiles, loadCaptionCues) { - - const CONTENTNODEID = nodeIds[0]; - const KIND = store.getters["contentNode/getContentNode"](CONTENTNODEID).kind; - - if(KIND === 'video' || KIND === 'audio') { - loadCaptionFiles({ contentnode_id: CONTENTNODEID }).then(captionFile => { - let captionFileID = captionFile[0].id; // FK to retrieve the cues of a caption file - loadCaptionCues({ caption_file_id: captionFileID }) - }) - } -} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index 5db52560cd..2e62788f3f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -1,8 +1,8 @@ import { CaptionFile, CaptionCues } from 'shared/data/resources'; -export async function loadCaptionFiles({ commit }, params) { +export async function loadCaptionFiles(commit, params) { const captionFiles = await CaptionFile.where(params); - commit('ADD_CAPTIONFILES', captionFiles); + commit('ADD_CAPTIONFILES', { captionFiles, nodeId: params.contentnode_id}); return captionFiles; } @@ -11,3 +11,21 @@ export async function loadCaptionCues({ commit }, { caption_file_id }) { commit('ADD_CAPTIONCUES', cues); return cues; } + +export async function loadCaptions({ commit, rootGetters }, params) { + const AI_FEATURE_FLAG = rootGetters['currentChannel/isAIFeatureEnabled'] + if(!AI_FEATURE_FLAG) return; + + const FILE_TYPE = rootGetters['contentNode/getContentNode'](params.contentnode_id).kind; + if(FILE_TYPE === 'video' || FILE_TYPE === 'audio') { + const captionFiles = await loadCaptionFiles(commit, params); + // If there is no Caption File for this contentnode + // Don't request for the cues + if(captionFiles.length === 0) return; + // TODO: call loadCaptionCues -> to be done after I finish saving captionFiles in indexedDB When CTA is called. So I have captions saved in the backend. + } +} + +export async function addCaptionFile({ commit }, { captionFile, nodeId }) { + commit('ADD_CAPTIONFILE', { captionFile, nodeId }); +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js index d74808f3d8..f1050ea879 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js @@ -1,3 +1,3 @@ -// export function getCaptionFiles(state) { -// return Object.values(state.captionFilesMap); -// } +export function getContentNodeId(state) { + return state.currentContentNode.contentnode_id; +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js index 399bc64436..e30006eda2 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js @@ -7,9 +7,16 @@ export default { namespaced: true, state: () => ({ /* List of caption files for a contentnode - * to be defined + * [ + * contentnode_id: { + * pk: { + * file_id: file_id + * language: language + * } + * }, + * ] */ - captionFilesMap: {}, + captionFilesMap: [], /* Caption Cues json to render in the frontend caption-editor * to be defined */ @@ -20,7 +27,7 @@ export default { actions, listeners: { [TABLE_NAMES.CAPTION_FILE]: { - [CHANGE_TYPES.CREATED]: 'ADD_CAPTIONFILES', + [CHANGE_TYPES.CREATED]: 'ADD_CAPTIONFILE', [CHANGE_TYPES.UPDATED]: 'UPDATE_CAPTIONFILE_FROM_INDEXEDDB', [CHANGE_TYPES.DELETED]: 'DELETE_CAPTIONFILE', }, diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js index 78970da8a0..dd0b071d24 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js @@ -1,16 +1,34 @@ import Vue from "vue"; /* Mutations for Caption File */ -export function ADD_CAPTIONFILE(state, captionFile) { - // TODO: add some checks to File +export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { + if(!state.captionFilesMap[nodeId]) { + Vue.set(state.captionFilesMap, nodeId, {}); + } + + // Check if the pk exists in the contentNode's object + if (!state.captionFilesMap[nodeId][captionFile.id]) { + // If it doesn't exist, create an empty object for that pk + Vue.set(state.captionFilesMap[nodeId], captionFile.id, {}); + } + + // Check if the file_id and language combination already exists + const key = `${captionFile.file_id}_${captionFile.language}`; + // if(state.captionFilesMap[nodeId][captionFile.id]) { + + // } - Vue.set(state.captionFilesMap, captionFile.id, captionFile); + // Finally, set the file_id and language for that pk + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'id', captionFile.id); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'file_id', captionFile.file_id); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'language', captionFile.language); + console.log(state.captionFilesMap); } -export function ADD_CAPTIONFILES(state, captionFiles = []) { - if (Array.isArray(captionFiles)) { // Workaround to fix TypeError: captionFiles.forEach +export function ADD_CAPTIONFILES(state, captionFiles, nodeId) { + if (Array.isArray(captionFiles)) { captionFiles.forEach(captionFile => { - ADD_CAPTIONFILE(state, captionFile); + ADD_CAPTIONFILE(state, captionFile, nodeId); }); } } From e9ed2e6029d749ae9b8e4ed47426da8979dc299c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Jul 2023 23:23:20 +0000 Subject: [PATCH 015/257] Bump semver from 5.7.1 to 5.7.2 Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2. - [Release notes](https://github.com/npm/node-semver/releases) - [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md) - [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2) --- updated-dependencies: - dependency-name: semver dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3be7dc115a..1d083bb580 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11699,9 +11699,9 @@ selfsigned@^2.1.1: node-forge "^1" "semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== semver@7.0.0: version "7.0.0" @@ -11709,21 +11709,14 @@ semver@7.0.0: integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -semver@^7.3.2, semver@^7.3.4, semver@^7.3.5: - version "7.3.7" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" - integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== - dependencies: - lru-cache "^6.0.0" - -semver@^7.3.8: - version "7.5.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.0.tgz#ed8c5dc8efb6c629c88b23d41dc9bf40c1d96cd0" - integrity sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA== + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.8: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== dependencies: lru-cache "^6.0.0" From 99d27bbf34b166b3ee9e82c05db12adb14543a13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jun 2023 01:36:59 +0000 Subject: [PATCH 016/257] Bump fonttools from 4.27.1 to 4.40.0 Bumps [fonttools](https://github.com/fonttools/fonttools) from 4.27.1 to 4.40.0. - [Release notes](https://github.com/fonttools/fonttools/releases) - [Changelog](https://github.com/fonttools/fonttools/blob/main/NEWS.rst) - [Commits](https://github.com/fonttools/fonttools/compare/4.27.1...4.40.0) --- updated-dependencies: - dependency-name: fonttools dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index e9c4bd9d4b..bbf36f418d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -90,7 +90,7 @@ flask-basicauth==0.2.0 # via locust flask-cors==3.0.10 # via locust -fonttools==4.27.1 +fonttools==4.40.0 # via -r requirements-dev.in gevent==21.12.0 # via From 1492b09adb2276353dcfc7bf991bfbd5dcaccfdd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Jul 2023 00:08:31 +0000 Subject: [PATCH 017/257] Bump workbox-precaching from 6.5.4 to 7.0.0 Bumps [workbox-precaching](https://github.com/googlechrome/workbox) from 6.5.4 to 7.0.0. - [Release notes](https://github.com/googlechrome/workbox/releases) - [Commits](https://github.com/googlechrome/workbox/compare/v6.5.4...v7.0.0) --- updated-dependencies: - dependency-name: workbox-precaching dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d16912ec71..3921040485 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "vue-router": "3.6.5", "vuetify": "^1.5.24", "vuex": "^3.0.1", - "workbox-precaching": "^6.5.4", + "workbox-precaching": "^7.0.0", "workbox-window": "^6.5.4" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 1d083bb580..5633182749 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13880,6 +13880,11 @@ workbox-core@6.5.4: resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-6.5.4.tgz#df48bf44cd58bb1d1726c49b883fb1dffa24c9ba" integrity sha512-OXYb+m9wZm8GrORlV2vBbE5EC1FKu71GGp0H4rjmxmF4/HLbMCoTFws87M3dFwgpmg0v00K++PImpNQ6J5NQ6Q== +workbox-core@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.0.0.tgz#dec114ec923cc2adc967dd9be1b8a0bed50a3545" + integrity sha512-81JkAAZtfVP8darBpfRTovHg8DGAVrKFgHpOArZbdFd78VqHr5Iw65f2guwjE2NlCFbPFDoez3D3/6ZvhI/rwQ== + workbox-expiration@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.5.4.tgz#501056f81e87e1d296c76570bb483ce5e29b4539" @@ -13905,7 +13910,7 @@ workbox-navigation-preload@6.5.4: dependencies: workbox-core "6.5.4" -workbox-precaching@6.5.4, workbox-precaching@^6.5.4: +workbox-precaching@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.5.4.tgz#740e3561df92c6726ab5f7471e6aac89582cab72" integrity sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg== @@ -13914,6 +13919,15 @@ workbox-precaching@6.5.4, workbox-precaching@^6.5.4: workbox-routing "6.5.4" workbox-strategies "6.5.4" +workbox-precaching@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.0.0.tgz#3979ba8033aadf3144b70e9fe631d870d5fbaa03" + integrity sha512-EC0vol623LJqTJo1mkhD9DZmMP604vHqni3EohhQVwhJlTgyKyOkMrZNy5/QHfOby+39xqC01gv4LjOm4HSfnA== + dependencies: + workbox-core "7.0.0" + workbox-routing "7.0.0" + workbox-strategies "7.0.0" + workbox-range-requests@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz#86b3d482e090433dab38d36ae031b2bb0bd74399" @@ -13940,6 +13954,13 @@ workbox-routing@6.5.4: dependencies: workbox-core "6.5.4" +workbox-routing@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-7.0.0.tgz#6668438a06554f60645aedc77244a4fe3a91e302" + integrity sha512-8YxLr3xvqidnbVeGyRGkaV4YdlKkn5qZ1LfEePW3dq+ydE73hUUJJuLmGEykW3fMX8x8mNdL0XrWgotcuZjIvA== + dependencies: + workbox-core "7.0.0" + workbox-strategies@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.5.4.tgz#4edda035b3c010fc7f6152918370699334cd204d" @@ -13947,6 +13968,13 @@ workbox-strategies@6.5.4: dependencies: workbox-core "6.5.4" +workbox-strategies@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.0.0.tgz#dcba32b3f3074476019049cc490fe1a60ea73382" + integrity sha512-dg3qJU7tR/Gcd/XXOOo7x9QoCI9nk74JopaJaYAQ+ugLi57gPsXycVdBnYbayVj34m6Y8ppPwIuecrzkpBVwbA== + dependencies: + workbox-core "7.0.0" + workbox-streams@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.5.4.tgz#1cb3c168a6101df7b5269d0353c19e36668d7d69" From ad33170eec139691389f0c1abae1add8b9de740a Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 23 May 2023 16:51:29 +0530 Subject: [PATCH 018/257] created captionviewset --- .../migrations/0143_generatedcaptions.py | 21 +++++++++++++++ contentcuration/contentcuration/models.py | 6 +++++ contentcuration/contentcuration/urls.py | 2 ++ .../contentcuration/viewsets/captions.py | 27 +++++++++++++++++++ .../contentcuration/viewsets/sync/base.py | 3 +++ .../viewsets/sync/constants.py | 2 ++ 6 files changed, 61 insertions(+) create mode 100644 contentcuration/contentcuration/migrations/0143_generatedcaptions.py create mode 100644 contentcuration/contentcuration/viewsets/captions.py diff --git a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py b/contentcuration/contentcuration/migrations/0143_generatedcaptions.py new file mode 100644 index 0000000000..0502d7e8bc --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_generatedcaptions.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.14 on 2023-05-23 11:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='GeneratedCaptions', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('generated_captions', models.JSONField()), + ('language', models.CharField(max_length=10)), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 2a9f99633f..c746529f11 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2057,6 +2057,12 @@ def __str__(self): return self.ietf_name() +class GeneratedCaptions(models.Model): + id = models.AutoField(primary_key=True) + generated_captions = models.JSONField() + language = models.CharField(max_length=10) + + ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index bb03f3876e..ad3bb991c4 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,6 +32,7 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet +from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.channel import AdminChannelViewSet from contentcuration.viewsets.channel import CatalogViewSet @@ -55,6 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") +router.register(r'captions', CaptionViewSet) router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py new file mode 100644 index 0000000000..84a5e3981c --- /dev/null +++ b/contentcuration/contentcuration/viewsets/captions.py @@ -0,0 +1,27 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from contentcuration.models import GeneratedCaptions + + +class GeneratedCaptionsSerializer(serializers.ModelSerializer): + class Meta: + model = GeneratedCaptions + fields = ['id', 'generated_captions', 'language'] + +class CaptionViewSet(ModelViewSet): + queryset = GeneratedCaptions.objects.all() + serializer_class = GeneratedCaptionsSerializer + + def create(self, request): + # handles the creation operation and return serialized data + pass + + def update(self, request): + # handles the updating of an existing `GeneratedCaption` instance. + pass + + def destroy(self, request): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index f11a8f4729..44e3861050 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,6 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet +from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet @@ -14,6 +15,7 @@ from contentcuration.viewsets.invitation import InvitationViewSet from contentcuration.viewsets.sync.constants import ASSESSMENTITEM from contentcuration.viewsets.sync.constants import BOOKMARK +from contentcuration.viewsets.sync.constants import CAPTION from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import CHANNELSET from contentcuration.viewsets.sync.constants import CLIPBOARD @@ -73,6 +75,7 @@ def __init__(self, change_type, viewset_class): (EDITOR_M2M, ChannelUserViewSet), (VIEWER_M2M, ChannelUserViewSet), (SAVEDSEARCH, SavedSearchViewSet), + (CAPTION, CaptionViewSet), ] ) diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index 84c2b5aad7..ffc3227873 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,6 +22,7 @@ # Client-side table constants BOOKMARK = "bookmark" +CAPTION = "captions" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" @@ -39,6 +40,7 @@ ALL_TABLES = set( [ BOOKMARK, + CAPTION, CHANNEL, CLIPBOARD, CONTENTNODE, From 4f0389ed6db4f27d6632488bf94fce4db350bea4 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Mon, 19 Jun 2023 12:40:39 +0530 Subject: [PATCH 019/257] Adds captions modal with visibility controlled by featureflag --- .../CaptionsEditor/CaptionsEditor.vue | 19 +++++++++ .../channelEdit/components/edit/EditView.vue | 39 +++++++++++++++--- .../frontend/channelEdit/constants.js | 1 + .../vuex/currentChannel/getters.js | 4 ++ .../frontend/shared/data/constants.js | 1 + .../frontend/shared/data/resources.js | 4 ++ ...3_generatedcaptions.py => 0143_caption.py} | 10 +++-- contentcuration/contentcuration/models.py | 10 +++-- contentcuration/contentcuration/urls.py | 4 +- .../contentcuration/viewsets/caption.py | 40 +++++++++++++++++++ .../contentcuration/viewsets/captions.py | 27 ------------- .../contentcuration/viewsets/sync/base.py | 2 +- .../viewsets/sync/constants.py | 2 +- 13 files changed, 120 insertions(+), 43 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue rename contentcuration/contentcuration/migrations/{0143_generatedcaptions.py => 0143_caption.py} (53%) create mode 100644 contentcuration/contentcuration/viewsets/caption.py delete mode 100644 contentcuration/contentcuration/viewsets/captions.py diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue new file mode 100644 index 0000000000..33f25de838 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue index f3ce855b2e..5f221e9b72 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue @@ -62,6 +62,16 @@ {{ relatedResourcesCount }} + + + + {{ $tr(tabs.CAPTIONS) }} + @@ -82,6 +92,7 @@ + + + + + @@ -104,6 +120,7 @@ import { TabNames } from '../../constants'; import AssessmentTab from '../../components/AssessmentTab/AssessmentTab'; + import CaptionsEditor from '../../components/CaptionsEditor/CaptionsEditor' import RelatedResourcesTab from '../../components/RelatedResourcesTab/RelatedResourcesTab'; import DetailsTabView from './DetailsTabView'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -113,11 +130,12 @@ export default { name: 'EditView', components: { - DetailsTabView, - AssessmentTab, - RelatedResourcesTab, - Tabs, - ToolBar, + AssessmentTab, + CaptionsEditor, + DetailsTabView, + RelatedResourcesTab, + Tabs, + ToolBar, }, props: { nodeIds: { @@ -143,6 +161,7 @@ 'getImmediateRelatedResourcesCount', ]), ...mapGetters('assessmentItem', ['getAssessmentItemsAreValid', 'getAssessmentItemsCount']), + ...mapGetters('currentChannel', ['isAIFeatureEnabled']), firstNode() { return this.nodes.length ? this.nodes[0] : null; }, @@ -167,6 +186,14 @@ showRelatedResourcesTab() { return this.oneSelected && this.firstNode && this.firstNode.kind !== 'topic'; }, + showCaptions() { + return ( + this.oneSelected && + this.firstNode && + (this.firstNode.kind === 'video' || this.firstNode.kind === 'audio') && + this.isAIFeatureEnabled + ) + }, countText() { const totals = reduce( this.nodes, @@ -260,6 +287,8 @@ questions: 'Questions', /** @see TabNames.RELATED */ related: 'Related', + /** @see TabNames.CAPTIONS */ + captions: 'Captions', /* eslint-enable kolibri/vue-no-unused-translations */ noItemsToEditText: 'Please select resources or folders to edit', invalidFieldsToolTip: 'Some required information is missing', diff --git a/contentcuration/contentcuration/frontend/channelEdit/constants.js b/contentcuration/contentcuration/frontend/channelEdit/constants.js index f36ebc5630..46a5d2d411 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/constants.js +++ b/contentcuration/contentcuration/frontend/channelEdit/constants.js @@ -55,6 +55,7 @@ export const TabNames = { PREVIEW: 'preview', QUESTIONS: 'questions', RELATED: 'related', + CAPTIONS: 'captions' }; export const modes = { diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js index ead653c2e4..3aeefe506b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js @@ -14,6 +14,10 @@ export function canEdit(state, getters, rootState, rootGetters) { ); } +export function isAIFeatureEnabled(state, getters, rootState, rootGetters) { + return rootGetters.featureFlags.ai_feature || false; +} + // Allow some extra actions for ricecooker channels export function canManage(state, getters, rootState, rootGetters) { return getters.currentChannel && (getters.currentChannel.edit || rootGetters.isAdmin); diff --git a/contentcuration/contentcuration/frontend/shared/data/constants.js b/contentcuration/contentcuration/frontend/shared/data/constants.js index 5f1ea5d357..fa93cc747e 100644 --- a/contentcuration/contentcuration/frontend/shared/data/constants.js +++ b/contentcuration/contentcuration/frontend/shared/data/constants.js @@ -46,6 +46,7 @@ export const TABLE_NAMES = { TASK: 'task', CHANGES_TABLE, BOOKMARK: 'bookmark', + CAPTION: 'caption' }; /** diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 3843753b6b..88e8fd4fd7 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1028,6 +1028,10 @@ export const Bookmark = new Resource({ getUserId: getUserIdFromStore, }); +export const Caption = new Resource({ + // TODO +}) + export const Channel = new Resource({ tableName: TABLE_NAMES.CHANNEL, urlName: 'channel', diff --git a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py b/contentcuration/contentcuration/migrations/0143_caption.py similarity index 53% rename from contentcuration/contentcuration/migrations/0143_generatedcaptions.py rename to contentcuration/contentcuration/migrations/0143_caption.py index 0502d7e8bc..3d6e0e769c 100644 --- a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py +++ b/contentcuration/contentcuration/migrations/0143_caption.py @@ -1,6 +1,8 @@ -# Generated by Django 3.2.14 on 2023-05-23 11:00 +# Generated by Django 3.2.14 on 2023-06-15 06:13 +import contentcuration.models from django.db import migrations, models +import uuid class Migration(migrations.Migration): @@ -11,10 +13,10 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='GeneratedCaptions', + name='Caption', fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('generated_captions', models.JSONField()), + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('caption', models.JSONField()), ('language', models.CharField(max_length=10)), ], ), diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index c746529f11..0d5c5eaa4a 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2057,10 +2057,14 @@ def __str__(self): return self.ietf_name() -class GeneratedCaptions(models.Model): - id = models.AutoField(primary_key=True) - generated_captions = models.JSONField() +class Caption(models.Model): + """ + Model to store captions and support intermediary changes + """ + id = UUIDField(primary_key=True, default=uuid.uuid4) + caption = models.JSONField() language = models.CharField(max_length=10) + # file_id = models.CharField(unique=True, max_length=32) ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index ad3bb991c4..581505918f 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,8 +32,8 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet -from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet +from contentcuration.viewsets.caption import CaptionViewSet from contentcuration.viewsets.channel import AdminChannelViewSet from contentcuration.viewsets.channel import CatalogViewSet from contentcuration.viewsets.channel import ChannelViewSet @@ -56,7 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") -router.register(r'captions', CaptionViewSet) +router.register(r'caption', CaptionViewSet) router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py new file mode 100644 index 0000000000..2dd2062bc7 --- /dev/null +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -0,0 +1,40 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from contentcuration.models import Caption + + +class CaptionSerializer(serializers.ModelSerializer): + class Meta: + model = Caption + fields = ["id", "caption", "language"] + + +class CaptionViewSet(ModelViewSet): + queryset = Caption.objects.all() + serializer_class = CaptionSerializer + + def create(self, request): + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + self.perform_create(serializer=serializer) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, headers=headers, status=status.HTTP_201_CREATED + ) + + def update(self, request, pk=None): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + if not serializer.is_valid(raise_exception=True): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py deleted file mode 100644 index 84a5e3981c..0000000000 --- a/contentcuration/contentcuration/viewsets/captions.py +++ /dev/null @@ -1,27 +0,0 @@ -from rest_framework import serializers, status -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from contentcuration.models import GeneratedCaptions - - -class GeneratedCaptionsSerializer(serializers.ModelSerializer): - class Meta: - model = GeneratedCaptions - fields = ['id', 'generated_captions', 'language'] - -class CaptionViewSet(ModelViewSet): - queryset = GeneratedCaptions.objects.all() - serializer_class = GeneratedCaptionsSerializer - - def create(self, request): - # handles the creation operation and return serialized data - pass - - def update(self, request): - # handles the updating of an existing `GeneratedCaption` instance. - pass - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index 44e3861050..879c67e123 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,7 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet -from contentcuration.viewsets.captions import CaptionViewSet +from contentcuration.viewsets.caption import CaptionViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index ffc3227873..1576953b50 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,7 +22,7 @@ # Client-side table constants BOOKMARK = "bookmark" -CAPTION = "captions" +CAPTION = "caption" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" From d6722016f73c2e54741aca81c9a37641d28cfb52 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:29:13 +0530 Subject: [PATCH 020/257] Adds Sync API tests for CaptionFile ViewSet --- .../constants/transcription_languages.py | 106 +++++++++++++ .../migrations/0143_caption.py | 23 --- .../migrations/0143_captioncue_captionfile.py | 37 +++++ contentcuration/contentcuration/models.py | 41 ++++- .../tests/viewsets/test_caption.py | 147 ++++++++++++++++++ .../contentcuration/viewsets/caption.py | 99 ++++++++---- .../contentcuration/viewsets/sync/base.py | 8 +- .../viewsets/sync/constants.py | 6 +- 8 files changed, 403 insertions(+), 64 deletions(-) create mode 100644 contentcuration/contentcuration/constants/transcription_languages.py delete mode 100644 contentcuration/contentcuration/migrations/0143_caption.py create mode 100644 contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py create mode 100644 contentcuration/contentcuration/tests/viewsets/test_caption.py diff --git a/contentcuration/contentcuration/constants/transcription_languages.py b/contentcuration/contentcuration/constants/transcription_languages.py new file mode 100644 index 0000000000..753c91e7d3 --- /dev/null +++ b/contentcuration/contentcuration/constants/transcription_languages.py @@ -0,0 +1,106 @@ +# This file contains a list of transcription languages. +# The list is in the format of (language code, language name). +# For example, the first element in the list is ('en', 'english'). + + +CAPTIONS_LANGUAGES = [ + ("en", "english"), + ("zh", "chinese"), + ("de", "german"), + ("es", "spanish"), + ("ru", "russian"), + ("ko", "korean"), + ("fr", "french"), + ("ja", "japanese"), + ("pt", "portuguese"), + ("tr", "turkish"), + ("pl", "polish"), + ("ca", "catalan"), + ("nl", "dutch"), + ("ar", "arabic"), + ("sv", "swedish"), + ("it", "italian"), + ("id", "indonesian"), + ("hi", "hindi"), + ("fi", "finnish"), + ("vi", "vietnamese"), + ("he", "hebrew"), + ("uk", "ukrainian"), + ("el", "greek"), + ("ms", "malay"), + ("cs", "czech"), + ("ro", "romanian"), + ("da", "danish"), + ("hu", "hungarian"), + ("ta", "tamil"), + ("no", "norwegian"), + ("th", "thai"), + ("ur", "urdu"), + ("hr", "croatian"), + ("bg", "bulgarian"), + ("lt", "lithuanian"), + ("la", "latin"), + ("mi", "maori"), + ("ml", "malayalam"), + ("cy", "welsh"), + ("sk", "slovak"), + ("te", "telugu"), + ("fa", "persian"), + ("lv", "latvian"), + ("bn", "bengali"), + ("sr", "serbian"), + ("az", "azerbaijani"), + ("sl", "slovenian"), + ("kn", "kannada"), + ("et", "estonian"), + ("mk", "macedonian"), + ("br", "breton"), + ("eu", "basque"), + ("is", "icelandic"), + ("hy", "armenian"), + ("ne", "nepali"), + ("mn", "mongolian"), + ("bs", "bosnian"), + ("kk", "kazakh"), + ("sq", "albanian"), + ("sw", "swahili"), + ("gl", "galician"), + ("mr", "marathi"), + ("pa", "punjabi"), + ("si", "sinhala"), + ("km", "khmer"), + ("sn", "shona"), + ("yo", "yoruba"), + ("so", "somali"), + ("af", "afrikaans"), + ("oc", "occitan"), + ("ka", "georgian"), + ("be", "belarusian"), + ("tg", "tajik"), + ("sd", "sindhi"), + ("gu", "gujarati"), + ("am", "amharic"), + ("yi", "yiddish"), + ("lo", "lao"), + ("uz", "uzbek"), + ("fo", "faroese"), + ("ht", "haitian creole"), + ("ps", "pashto"), + ("tk", "turkmen"), + ("nn", "nynorsk"), + ("mt", "maltese"), + ("sa", "sanskrit"), + ("lb", "luxembourgish"), + ("my", "myanmar"), + ("bo", "tibetan"), + ("tl", "tagalog"), + ("mg", "malagasy"), + ("as", "assamese"), + ("tt", "tatar"), + ("haw", "hawaiian"), + ("ln", "lingala"), + ("ha", "hausa"), + ("ba", "bashkir"), + ("jw", "javanese"), + ("su", "sundanese"), +] \ No newline at end of file diff --git a/contentcuration/contentcuration/migrations/0143_caption.py b/contentcuration/contentcuration/migrations/0143_caption.py deleted file mode 100644 index 3d6e0e769c..0000000000 --- a/contentcuration/contentcuration/migrations/0143_caption.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-15 06:13 - -import contentcuration.models -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='Caption', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('caption', models.JSONField()), - ('language', models.CharField(max_length=10)), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py new file mode 100644 index 0000000000..b8176d6a02 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-06-26 17:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 0d5c5eaa4a..91a85f1fc1 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,6 +2,7 @@ import json import logging import os +from typing import Any import urllib.parse import uuid from datetime import datetime @@ -68,6 +69,7 @@ from contentcuration.constants import channel_history from contentcuration.constants import completion_criteria from contentcuration.constants import user_history +from contentcuration.constants.transcription_languages import CAPTIONS_LANGUAGES from contentcuration.constants.contentnode import kind_activity_map from contentcuration.db.models.expressions import Array from contentcuration.db.models.functions import ArrayRemove @@ -2057,15 +2059,42 @@ def __str__(self): return self.ietf_name() -class Caption(models.Model): +class CaptionFile(models.Model): """ - Model to store captions and support intermediary changes + Represents a caption file record. + + - file_id: The identifier of related file in Google Cloud Storage. + - language: The language of the caption file. + """ + id = UUIDField(primary_key=True, default=uuid.uuid4) + file_id = UUIDField(default=uuid.uuid4, max_length=36) + language = models.CharField(choices=CAPTIONS_LANGUAGES, max_length=3) + + class Meta: + unique_together = ['file_id', 'language'] + + def __str__(self): + return "{file_id} -> {language}".format(file_id=self.file_id, language=self.language) + + +class CaptionCue(models.Model): + """ + Represents a caption cue in a VTT file. + + - text: The caption text. + - starttime: The start time of the cue in seconds. + - endtime: The end time of the cue in seconds. + - caption_file (Foreign Key): The related caption file. """ id = UUIDField(primary_key=True, default=uuid.uuid4) - caption = models.JSONField() - language = models.CharField(max_length=10) - # file_id = models.CharField(unique=True, max_length=32) - + text = models.TextField(null=False) + starttime = models.FloatField(null=False) + endtime = models.FloatField(null=False) + caption_file = models.ForeignKey(CaptionFile, related_name="caption_cue", on_delete=models.CASCADE) + + def __str__(self): + return "text: {text}, start_time: {starttime}, end_time: {endtime}".format(text=self.text, starttime=self.starttime, endtime=self.endtime) + ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py new file mode 100644 index 0000000000..07ffe9dfa0 --- /dev/null +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -0,0 +1,147 @@ +from __future__ import absolute_import + +import uuid + +from django.urls import reverse + +from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.tests.base import StudioAPITestCase +from contentcuration.tests import testdata +from contentcuration.tests.viewsets.base import SyncTestMixin +from contentcuration.tests.viewsets.base import generate_create_event +from contentcuration.tests.viewsets.base import generate_update_event +from contentcuration.tests.viewsets.base import generate_delete_event +from contentcuration.viewsets.sync.constants import CAPTION_FILE + +# class CRUDTestCase(StudioAPITestCase): + +class SyncTestCase(SyncTestMixin, StudioAPITestCase): + + @property + def caption_file_metadata(self): + return { + "file_id": uuid.uuid4().hex, + "language": "en", + } + + @property + def same_file_different_language_metadata(self): + id = uuid.uuid4().hex + return [ + { + "file_id": id, + "language": "en", + }, + { + "file_id": id, + "language": "ru", + } + ] + + @property + def caption_file_db_metadata(self): + return { + "file_id": uuid.uuid4().hex, + "language": "en", + } + + + def setUp(self): + super(SyncTestCase, self).setUp() + self.channel = testdata.channel() + self.user = testdata.user() + self.channel.editors.add(self.user) + + + def test_create_caption(self): + self.client.force_authenticate(user=self.user) + caption_file = self.caption_file_metadata + + response = self.sync_changes( + [ + generate_create_event( + uuid.uuid4().hex, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + + try: + caption_file_db = CaptionFile.objects.get( + file_id=caption_file["file_id"], + language=caption_file["language"], + ) + except CaptionFile.DoesNotExist: + self.fail("caption file was not created") + + # Check the values of the object in the PostgreSQL + self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) + self.assertEqual(caption_file_db.language, caption_file["language"]) + + def test_delete_caption_file(self): + self.client.force_authenticate(user=self.user) + caption_file = self.caption_file_metadata + pk = uuid.uuid4().hex + response = self.sync_changes( + [ + generate_create_event( + pk, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id + ) + ] + ) + self.assertEqual(response.status_code, 200, response.content) + + # Delete the caption file + response = self.sync_changes( + [ + generate_delete_event( + pk, + CAPTION_FILE, + channel_id=self.channel.id + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + + with self.assertRaises(CaptionFile.DoesNotExist): + caption_file_db = CaptionFile.objects.get( + file_id=caption_file['file_id'], + language=caption_file['language'] + ) + + + def test_delete_file_with_same_file_id_different_language(self): + self.client.force_authenticate(user=self.user) + obj = self.same_file_different_language_metadata + + caption_file_1 = CaptionFile.objects.create( + **obj[0] + ) + caption_file_2 = CaptionFile.objects.create( + **obj[1] + ) + + response = self.sync_changes( + [ + generate_delete_event( + caption_file_2.pk, + CAPTION_FILE, + channel_id=self.channel.id, + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + + with self.assertRaises(CaptionFile.DoesNotExist): + caption_file_db = CaptionFile.objects.get( + file_id=caption_file_2.file_id, + language=caption_file_2.language + ) diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 2dd2062bc7..1a62b191d5 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,40 +1,79 @@ from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from contentcuration.models import Caption +from contentcuration.models import CaptionCue +from contentcuration.models import CaptionFile +from contentcuration.viewsets.base import ValuesViewset + +from contentcuration.viewsets.sync.utils import log_sync_exception + +from django.core.exceptions import ObjectDoesNotExist + + +""" +[x] create file - POST /api/caption?file_id=..&language=.. +[x] delete file - DELETE /api/caption?file_id=..&language=.. + +[] create file cue - POST /api/caption/cue?file_id=..&language=.. +[] update file cue - PATCH /api/caption/cue?file_id=..&language=..&cue_id=.. +[] delete file cue - DELETE /api/caption/cue?file_id=..&language=..&cue_id=.. + +[] get the file cues - GET /api/caption?file_id=..&language=.. +""" + + +class CueSerializer(serializers.ModelSerializer): + class Meta: + model = CaptionCue + fields = ["text", "starttime", "endtime"] class CaptionSerializer(serializers.ModelSerializer): + caption_cue = CueSerializer(many=True, required=False) + class Meta: - model = Caption - fields = ["id", "caption", "language"] + model = CaptionFile + fields = ["file_id", "language", "caption_cue"] -class CaptionViewSet(ModelViewSet): - queryset = Caption.objects.all() +class CaptionViewSet(ValuesViewset): + # Handles operations for the CaptionFile model. + queryset = CaptionFile.objects.prefetch_related("caption_cue") + permission_classes = [IsAuthenticated] serializer_class = CaptionSerializer + values = ("file_id", "language", "caption_cue") + + field_map = {"file": "file_id", "language": "language"} + + def delete_from_changes(self, changes): + errors = [] + queryset = self.get_edit_queryset().order_by() + for change in changes: + try: + instance = queryset.filter(**dict(self.values_from_key(change["key"]))) + + self.perform_destroy(instance) + except ObjectDoesNotExist: + # If the object already doesn't exist, as far as the user is concerned + # job done! + pass + except Exception as e: + log_sync_exception(e, user=self.request.user, change=change) + change["errors"] = [str(e)] + errors.append(change) + return errors + + +class CaptionCueViewSet(ValuesViewset): + # Handles operations for the CaptionCue model. + queryset = CaptionCue.objects.all() + permission_classes = [IsAuthenticated] + serializer_class = CueSerializer + values = ("text", "starttime", "endtime") - def create(self, request): - serializer = self.get_serializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - self.perform_create(serializer=serializer) - headers = self.get_success_headers(serializer.data) - return Response( - serializer.data, headers=headers, status=status.HTTP_201_CREATED - ) - - def update(self, request, pk=None): - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=True) - if not serializer.is_valid(raise_exception=True): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) + field_map = { + "text": "text", + "start_time": "starttime", + "end_time": "endtime", + } + # Add caption file in field_map? diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index 879c67e123..7606853bcc 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,7 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet -from contentcuration.viewsets.caption import CaptionViewSet +from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet @@ -15,7 +15,8 @@ from contentcuration.viewsets.invitation import InvitationViewSet from contentcuration.viewsets.sync.constants import ASSESSMENTITEM from contentcuration.viewsets.sync.constants import BOOKMARK -from contentcuration.viewsets.sync.constants import CAPTION +from contentcuration.viewsets.sync.constants import CAPTION_CUES +from contentcuration.viewsets.sync.constants import CAPTION_FILE from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import CHANNELSET from contentcuration.viewsets.sync.constants import CLIPBOARD @@ -75,7 +76,8 @@ def __init__(self, change_type, viewset_class): (EDITOR_M2M, ChannelUserViewSet), (VIEWER_M2M, ChannelUserViewSet), (SAVEDSEARCH, SavedSearchViewSet), - (CAPTION, CaptionViewSet), + (CAPTION_FILE, CaptionViewSet), + (CAPTION_CUES, CaptionCueViewSet), ] ) diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index 1576953b50..6ad7305c6c 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,7 +22,8 @@ # Client-side table constants BOOKMARK = "bookmark" -CAPTION = "caption" +CAPTION_FILE = "caption_file" +CAPTION_CUES = "caption_cues" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" @@ -40,7 +41,8 @@ ALL_TABLES = set( [ BOOKMARK, - CAPTION, + CAPTION_FILE, + CAPTION_CUES, CHANNEL, CLIPBOARD, CONTENTNODE, From 9aa7ac5fbe2aa18db25f6a150b0ba58918ee1b1a Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:33:46 +0530 Subject: [PATCH 021/257] Removes unnecessary imports --- contentcuration/contentcuration/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 91a85f1fc1..827fe20aa4 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,7 +2,6 @@ import json import logging import os -from typing import Any import urllib.parse import uuid from datetime import datetime From df961f08e0452b1b9a48e0bdebe35a993bba56ed Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:35:36 +0530 Subject: [PATCH 022/257] Fixes text formatting --- .../tests/viewsets/test_caption.py | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 07ffe9dfa0..4e6eb45132 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -15,8 +15,8 @@ # class CRUDTestCase(StudioAPITestCase): -class SyncTestCase(SyncTestMixin, StudioAPITestCase): +class SyncTestCase(SyncTestMixin, StudioAPITestCase): @property def caption_file_metadata(self): return { @@ -35,7 +35,7 @@ def same_file_different_language_metadata(self): { "file_id": id, "language": "ru", - } + }, ] @property @@ -45,18 +45,16 @@ def caption_file_db_metadata(self): "language": "en", } - def setUp(self): super(SyncTestCase, self).setUp() self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata - + response = self.sync_changes( [ generate_create_event( @@ -88,10 +86,7 @@ def test_delete_caption_file(self): response = self.sync_changes( [ generate_create_event( - pk, - CAPTION_FILE, - caption_file, - channel_id=self.channel.id + pk, CAPTION_FILE, caption_file, channel_id=self.channel.id ) ] ) @@ -99,34 +94,22 @@ def test_delete_caption_file(self): # Delete the caption file response = self.sync_changes( - [ - generate_delete_event( - pk, - CAPTION_FILE, - channel_id=self.channel.id - ) - ] + [generate_delete_event(pk, CAPTION_FILE, channel_id=self.channel.id)] ) self.assertEqual(response.status_code, 200, response.content) with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file['file_id'], - language=caption_file['language'] + file_id=caption_file["file_id"], language=caption_file["language"] ) - def test_delete_file_with_same_file_id_different_language(self): self.client.force_authenticate(user=self.user) obj = self.same_file_different_language_metadata - caption_file_1 = CaptionFile.objects.create( - **obj[0] - ) - caption_file_2 = CaptionFile.objects.create( - **obj[1] - ) + caption_file_1 = CaptionFile.objects.create(**obj[0]) + caption_file_2 = CaptionFile.objects.create(**obj[1]) response = self.sync_changes( [ @@ -142,6 +125,5 @@ def test_delete_file_with_same_file_id_different_language(self): with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file_2.file_id, - language=caption_file_2.language + file_id=caption_file_2.file_id, language=caption_file_2.language ) From 551c1ad7d2bb72ac7f0fac26a0d48f29180683c5 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:11:16 +0530 Subject: [PATCH 023/257] Creating CaptionCue with generate_create_event fails --- .../CaptionsEditor/CaptionsEditor.vue | 2 +- .../tests/viewsets/test_caption.py | 115 +++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 33f25de838..531dc7d90f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -16,4 +16,4 @@ \ No newline at end of file + diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 4e6eb45132..efd2bddc42 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -2,16 +2,17 @@ import uuid -from django.urls import reverse +import json from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.viewsets.caption import CaptionFileSerializer from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests import testdata from contentcuration.tests.viewsets.base import SyncTestMixin from contentcuration.tests.viewsets.base import generate_create_event from contentcuration.tests.viewsets.base import generate_update_event from contentcuration.tests.viewsets.base import generate_delete_event -from contentcuration.viewsets.sync.constants import CAPTION_FILE +from contentcuration.viewsets.sync.constants import CAPTION_FILE, CAPTION_CUES # class CRUDTestCase(StudioAPITestCase): @@ -45,12 +46,26 @@ def caption_file_db_metadata(self): "language": "en", } + @property + def caption_cue_metadata(self): + return { + "file": { + "file_id": uuid.uuid4().hex, + "language": "en", + }, + "cue": { + "text": "This is the beginning!", + "starttime": 0.0, + "endtime": 12.0, + }, + } + def setUp(self): super(SyncTestCase, self).setUp() self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - + # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata @@ -127,3 +142,97 @@ def test_delete_file_with_same_file_id_different_language(self): caption_file_db = CaptionFile.objects.get( file_id=caption_file_2.file_id, language=caption_file_2.language ) + + def test_caption_file_serialization(self): + metadata = self.caption_file_metadata + caption_file = CaptionFile.objects.create(**metadata) + serializer = CaptionFileSerializer(instance=caption_file) + try: + jd = json.dumps(serializer.data) # Try to serialize the data to JSON + except Exception as e: + self.fail(f"CaptionFile serialization failed. Error: {str(e)}") + + def test_caption_cue_serialization(self): + metadata = self.caption_cue_metadata + caption_file = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file, + }) + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + caption_cue_2 = CaptionCue.objects.create( + text='How are you?', + starttime=2.0, + endtime=3.0, + caption_file=caption_file + ) + serializer = CaptionFileSerializer(instance=caption_file) + try: + json.dumps(serializer.data) # Try to serialize the data to JSON + except Exception as e: + self.fail(f"CaptionFile serialization failed. Error: {str(e)}") + + def test_create_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + caption_file_1 = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file_1, + }) + # This works: caption_cue_1 = CaptionCue.objects.create(**caption_cue) + response = self.sync_changes( + [ + generate_create_event( + uuid.uuid4(), + CAPTION_CUES, + caption_cue, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + def test_delete_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + caption_file_1 = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file_1, + }) + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + # Delete the caption Cue that we just created + response = self.sync_changes( + [generate_delete_event(caption_cue_db.pk , CAPTION_CUES, channel_id=self.channel.id)] + ) + self.assertEqual(response.status_code, 200, response.content) + + caption_cue_db_exists = CaptionCue.objects.filter( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ).exists() + if caption_cue_db_exists: + self.fail("Caption Cue still exists!") + + def test_update_caption_cue(self): + pass From 6bfd92767324747537dbda6a16687d1340f270a7 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:46:25 +0530 Subject: [PATCH 024/257] Add failing test for CaptionFile JSON serialization --- contentcuration/contentcuration/urls.py | 2 +- .../contentcuration/viewsets/caption.py | 37 ++++++------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 581505918f..763db0f696 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -56,7 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") -router.register(r'caption', CaptionViewSet) +router.register(r'captions', CaptionViewSet, basename="captions") router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 1a62b191d5..55eb822976 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,35 +1,19 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.http import JsonResponse from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from contentcuration.models import CaptionCue -from contentcuration.models import CaptionFile +from contentcuration.models import CaptionCue, CaptionFile from contentcuration.viewsets.base import ValuesViewset - from contentcuration.viewsets.sync.utils import log_sync_exception -from django.core.exceptions import ObjectDoesNotExist - - -""" -[x] create file - POST /api/caption?file_id=..&language=.. -[x] delete file - DELETE /api/caption?file_id=..&language=.. - -[] create file cue - POST /api/caption/cue?file_id=..&language=.. -[] update file cue - PATCH /api/caption/cue?file_id=..&language=..&cue_id=.. -[] delete file cue - DELETE /api/caption/cue?file_id=..&language=..&cue_id=.. - -[] get the file cues - GET /api/caption?file_id=..&language=.. -""" - - -class CueSerializer(serializers.ModelSerializer): +class CaptionCueSerializer(serializers.ModelSerializer): class Meta: model = CaptionCue fields = ["text", "starttime", "endtime"] - -class CaptionSerializer(serializers.ModelSerializer): - caption_cue = CueSerializer(many=True, required=False) +class CaptionFileSerializer(serializers.ModelSerializer): + caption_cue = CaptionCueSerializer(many=True, required=False) class Meta: model = CaptionFile @@ -40,10 +24,13 @@ class CaptionViewSet(ValuesViewset): # Handles operations for the CaptionFile model. queryset = CaptionFile.objects.prefetch_related("caption_cue") permission_classes = [IsAuthenticated] - serializer_class = CaptionSerializer + serializer_class = CaptionFileSerializer values = ("file_id", "language", "caption_cue") - field_map = {"file": "file_id", "language": "language"} + field_map = { + "file": "file_id", + "language": "language" + } def delete_from_changes(self, changes): errors = [] @@ -68,7 +55,7 @@ class CaptionCueViewSet(ValuesViewset): # Handles operations for the CaptionCue model. queryset = CaptionCue.objects.all() permission_classes = [IsAuthenticated] - serializer_class = CueSerializer + serializer_class = CaptionCueSerializer values = ("text", "starttime", "endtime") field_map = { From 9c697776de1e88a472125e4132e29e18b7420b34 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 7 Jul 2023 13:12:04 +0530 Subject: [PATCH 025/257] Adds caption editor components, updated IndexedDB Resource --- .../CaptionsEditor/CaptionsEditor.vue | 15 +- .../frontend/channelEdit/constants.js | 2 +- .../channelEdit/vuex/caption/actions.js | 12 ++ .../channelEdit/vuex/caption/getters.js | 11 ++ .../channelEdit/vuex/caption/index.js | 26 +++ .../channelEdit/vuex/caption/mutations.js | 12 ++ .../frontend/shared/data/constants.js | 3 +- .../frontend/shared/data/resources.js | 17 +- contentcuration/contentcuration/models.py | 3 +- .../tests/viewsets/test_caption.py | 168 +++++++++++++----- contentcuration/contentcuration/urls.py | 3 +- .../contentcuration/viewsets/caption.py | 47 ++++- 12 files changed, 252 insertions(+), 67 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 531dc7d90f..4efd617226 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -1,17 +1,24 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue new file mode 100644 index 0000000000..ef7322e50d --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 4cbf88eadd..91401ed102 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -25,7 +25,6 @@ import { mapGetters, mapActions } from 'vuex'; import EditListItem from './EditListItem'; import Checkbox from 'shared/views/form/Checkbox'; - import { loadCaption } from '../../utils'; export default { name: 'EditList', @@ -44,16 +43,15 @@ }, }, watch: { - // watch the selected contentnode + // watch the selected contentnode to load captions value(newValue) { - if (this.isAIFeatureEnabled && newValue.length === 1) { - const nodeId = newValue[0]; - loadCaption([nodeId], this.loadCaptionFiles, this.loadCaptionCues); + if (newValue.length === 1) { + const nodeId = newValue[0]; // we dispatch action only when 1 node is selected + this.loadCaptions({ contentnode_id: nodeId }) } } }, computed: { - ...mapGetters('currentChannel', ['isAIFeatureEnabled']), selected: { get() { return this.value; @@ -77,7 +75,7 @@ }, }, methods: { - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), handleRemoved(nodeId) { const nodeIds = this.$route.params.detailNodeIds.split(',').filter(id => id !== nodeId); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 7547118227..09973325a5 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -197,7 +197,6 @@ import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; import { DELAYED_VALIDATION } from 'shared/constants'; - import { loadCaption } from '../../utils'; const CHECK_STORAGE_INTERVAL = 10000; @@ -251,7 +250,7 @@ computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), - ...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'isAIFeatureEnabled']), + ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), ...mapGetters('file', ['contentNodesAreUploading', 'getContentNodeFiles']), ...mapState({ online: state => state.connection.online, @@ -307,11 +306,6 @@ return this.nodeIds.filter(id => !this.getContentNodeIsValid(id)); }, }, - created() { - if (this.isAIFeatureEnabled && this.nodeIds.length === 1) { - loadCaption(this.nodeIds, this.loadCaptionFiles, this.loadCaptionCues); - } - }, beforeRouteEnter(to, from, next) { if ( to.name === RouteNames.CONTENTNODE_DETAILS || @@ -347,6 +341,11 @@ vm.loadFiles({ contentnode__in: childrenNodesIds }), vm.loadAssessmentItems({ contentnode__in: childrenNodesIds }), ]; + if(childrenNodesIds.length === 1) { + promises.push( + vm.loadCaptions({ contentnode_id: childrenNodesIds }) + ); + } } else { // no need to load assessment items or files as topics have none promises = [vm.loadContentNode(parentTopicId)]; @@ -400,7 +399,7 @@ ]), ...mapActions('file', ['loadFiles', 'updateFile']), ...mapActions('assessmentItem', ['loadAssessmentItems', 'updateAssessmentItems']), - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), ...mapMutations('contentNode', { enableValidation: 'ENABLE_VALIDATION_ON_NODES' }), closeModal() { this.promptUploading = false; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue index 5f221e9b72..f32b5d9709 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue @@ -103,7 +103,7 @@ - + @@ -120,7 +120,7 @@ import { TabNames } from '../../constants'; import AssessmentTab from '../../components/AssessmentTab/AssessmentTab'; - import CaptionsEditor from '../../components/CaptionsEditor/CaptionsEditor' + import CaptionsTab from '../../components/CaptionsTab/CaptionsTab' import RelatedResourcesTab from '../../components/RelatedResourcesTab/RelatedResourcesTab'; import DetailsTabView from './DetailsTabView'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -131,7 +131,7 @@ name: 'EditView', components: { AssessmentTab, - CaptionsEditor, + CaptionsTab, DetailsTabView, RelatedResourcesTab, Tabs, diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index ce425a1345..a475d4e67f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,4 +1,3 @@ -import store from './store'; import translator from './translator'; import { RouteNames } from './constants'; import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; @@ -295,28 +294,3 @@ export function getCompletionCriteriaLabels(node) { return labels; } - -/** - * Loads caption files and cues for a content node. - * - * Caption files and cues are only loaded when: - * - The AI feature flag is enabled - * - A single node is being edited - * - The node kind is 'video' or 'audio' - * - * @param {Array} nodeIds - Array containing node ID(s) being edited - * @param {Function} loadCaptionFiles - Vuex Action to load caption files - * @param {Function} loadCaptionCues - Vuex Action to load caption cues - */ -export function loadCaption(nodeIds, loadCaptionFiles, loadCaptionCues) { - - const CONTENTNODEID = nodeIds[0]; - const KIND = store.getters["contentNode/getContentNode"](CONTENTNODEID).kind; - - if(KIND === 'video' || KIND === 'audio') { - loadCaptionFiles({ contentnode_id: CONTENTNODEID }).then(captionFile => { - let captionFileID = captionFile[0].id; // FK to retrieve the cues of a caption file - loadCaptionCues({ caption_file_id: captionFileID }) - }) - } -} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index 5db52560cd..2e62788f3f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -1,8 +1,8 @@ import { CaptionFile, CaptionCues } from 'shared/data/resources'; -export async function loadCaptionFiles({ commit }, params) { +export async function loadCaptionFiles(commit, params) { const captionFiles = await CaptionFile.where(params); - commit('ADD_CAPTIONFILES', captionFiles); + commit('ADD_CAPTIONFILES', { captionFiles, nodeId: params.contentnode_id}); return captionFiles; } @@ -11,3 +11,21 @@ export async function loadCaptionCues({ commit }, { caption_file_id }) { commit('ADD_CAPTIONCUES', cues); return cues; } + +export async function loadCaptions({ commit, rootGetters }, params) { + const AI_FEATURE_FLAG = rootGetters['currentChannel/isAIFeatureEnabled'] + if(!AI_FEATURE_FLAG) return; + + const FILE_TYPE = rootGetters['contentNode/getContentNode'](params.contentnode_id).kind; + if(FILE_TYPE === 'video' || FILE_TYPE === 'audio') { + const captionFiles = await loadCaptionFiles(commit, params); + // If there is no Caption File for this contentnode + // Don't request for the cues + if(captionFiles.length === 0) return; + // TODO: call loadCaptionCues -> to be done after I finish saving captionFiles in indexedDB When CTA is called. So I have captions saved in the backend. + } +} + +export async function addCaptionFile({ commit }, { captionFile, nodeId }) { + commit('ADD_CAPTIONFILE', { captionFile, nodeId }); +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js index d74808f3d8..f1050ea879 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js @@ -1,3 +1,3 @@ -// export function getCaptionFiles(state) { -// return Object.values(state.captionFilesMap); -// } +export function getContentNodeId(state) { + return state.currentContentNode.contentnode_id; +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js index 399bc64436..e30006eda2 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js @@ -7,9 +7,16 @@ export default { namespaced: true, state: () => ({ /* List of caption files for a contentnode - * to be defined + * [ + * contentnode_id: { + * pk: { + * file_id: file_id + * language: language + * } + * }, + * ] */ - captionFilesMap: {}, + captionFilesMap: [], /* Caption Cues json to render in the frontend caption-editor * to be defined */ @@ -20,7 +27,7 @@ export default { actions, listeners: { [TABLE_NAMES.CAPTION_FILE]: { - [CHANGE_TYPES.CREATED]: 'ADD_CAPTIONFILES', + [CHANGE_TYPES.CREATED]: 'ADD_CAPTIONFILE', [CHANGE_TYPES.UPDATED]: 'UPDATE_CAPTIONFILE_FROM_INDEXEDDB', [CHANGE_TYPES.DELETED]: 'DELETE_CAPTIONFILE', }, diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js index 78970da8a0..dd0b071d24 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js @@ -1,16 +1,34 @@ import Vue from "vue"; /* Mutations for Caption File */ -export function ADD_CAPTIONFILE(state, captionFile) { - // TODO: add some checks to File +export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { + if(!state.captionFilesMap[nodeId]) { + Vue.set(state.captionFilesMap, nodeId, {}); + } + + // Check if the pk exists in the contentNode's object + if (!state.captionFilesMap[nodeId][captionFile.id]) { + // If it doesn't exist, create an empty object for that pk + Vue.set(state.captionFilesMap[nodeId], captionFile.id, {}); + } + + // Check if the file_id and language combination already exists + const key = `${captionFile.file_id}_${captionFile.language}`; + // if(state.captionFilesMap[nodeId][captionFile.id]) { + + // } - Vue.set(state.captionFilesMap, captionFile.id, captionFile); + // Finally, set the file_id and language for that pk + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'id', captionFile.id); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'file_id', captionFile.file_id); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'language', captionFile.language); + console.log(state.captionFilesMap); } -export function ADD_CAPTIONFILES(state, captionFiles = []) { - if (Array.isArray(captionFiles)) { // Workaround to fix TypeError: captionFiles.forEach +export function ADD_CAPTIONFILES(state, captionFiles, nodeId) { + if (Array.isArray(captionFiles)) { captionFiles.forEach(captionFile => { - ADD_CAPTIONFILE(state, captionFile); + ADD_CAPTIONFILE(state, captionFile, nodeId); }); } } From fe278e2f159a1eac08592bcbcc7c7fdcf508b126 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Thu, 27 Jul 2023 22:46:37 +0530 Subject: [PATCH 032/257] Stage changes before rebase --- .../components/CaptionsTab/CaptionsTab.vue | 31 ++---- .../components/CaptionsTab/languages.js | 101 ++++++++++++++++++ .../channelEdit/components/edit/EditList.vue | 11 -- .../channelEdit/components/edit/EditModal.vue | 6 +- .../channelEdit/vuex/caption/actions.js | 54 +++++++--- .../channelEdit/vuex/caption/mutations.js | 17 ++- .../contentcuration/viewsets/caption.py | 6 +- 7 files changed, 164 insertions(+), 62 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue index ef7322e50d..eaa2346916 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue @@ -20,7 +20,7 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index 5c9c2d6a21..53d5223333 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -37,7 +37,9 @@ export async function loadCaptions({ commit, rootGetters }, params) { // If there is no Caption File for this contentnode // Don't request for the cues if(captionFiles.length === 0) return; - // TODO: call loadCaptionCues -> to be done after I finish saving captionFiles in indexedDB When CTA is called. So I have captions saved in the backend. + // TODO: call loadCaptionCues -> to be done after + // I finish saving captionFiles in indexedDB When + // CTA is called. So I have captions saved in the backend. } @@ -46,7 +48,7 @@ export async function addCaptionFile({ commit }, { file_id, language, nodeId }) file_id: file_id, language: language } - return CaptionFile.put(captionFile).then(id => { + return CaptionFile.add(captionFile).then(id => { captionFile.id = id; console.log(captionFile, nodeId); commit('ADD_CAPTIONFILE', { diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 890c5c1e3d..fd41792b62 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1034,6 +1034,7 @@ export const CaptionFile = new Resource({ idField: 'id', indexFields: ['file_id', 'language'], syncable: true, + getChannelId: getChannelFromChannelScope, }); export const CaptionCues = new Resource({ @@ -1042,6 +1043,7 @@ export const CaptionCues = new Resource({ idField: 'id', indexFields: ['text', 'starttime', 'endtime'], syncable: true, + getChannelId: getChannelFromChannelScope, collectionUrl(caption_file_id) { return this.getUrlFunction('list')(caption_file_id) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js b/contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js similarity index 73% rename from contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js rename to contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js index 703d856715..c7bf2a4f1e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js +++ b/contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js @@ -1,4 +1,13 @@ -export const CAPTIONS_LANGUAGES = { +/** + * This file generates the list of supported caption languages by + * filtering the full list of languages against the whisperLanguages object. + * To switch to a new model for supported languages, you can update the + * whisperLanguages object with new language codes and names. +*/ + +import { LanguagesList } from 'shared/leUtils/Languages'; + +const whisperLanguages = { en: "english", zh: "chinese", de: "german", @@ -98,4 +107,14 @@ export const CAPTIONS_LANGUAGES = { ba: "bashkir", jw: "javanese", su: "sundanese", -} \ No newline at end of file +} + +export const supportedCaptionLanguages = LanguagesList.filter( + (language) => whisperLanguages.hasOwnProperty(language.lang_code) +); + +export const notSupportedCaptionLanguages = LanguagesList.filter( + (language) => !whisperLanguages.hasOwnProperty(language.lang_code) +); + +export default supportedCaptionLanguages; diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py deleted file mode 100644 index b8176d6a02..0000000000 --- a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-26 17:33 - -import contentcuration.models -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='CaptionFile', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), - ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), - ], - options={ - 'unique_together': {('file_id', 'language')}, - }, - ), - migrations.CreateModel( - name='CaptionCue', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('text', models.TextField()), - ('starttime', models.FloatField()), - ('endtime', models.FloatField()), - ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0145_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0145_captioncue_captionfile.py new file mode 100644 index 0000000000..29d5bffb97 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0145_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-08-01 06:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0144_soft_delete_user'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_file', to='contentcuration.language')), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 17e165172d..777ec96b4b 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2067,13 +2067,19 @@ class CaptionFile(models.Model): """ id = UUIDField(primary_key=True, default=uuid.uuid4) file_id = UUIDField(default=uuid.uuid4, max_length=36) - language = models.CharField(choices=CAPTIONS_LANGUAGES, max_length=3) + language = models.ForeignKey(Language, related_name="caption_file", on_delete=models.CASCADE) class Meta: unique_together = ['file_id', 'language'] def __str__(self): return "file_id: {file_id}, language: {language}".format(file_id=self.file_id, language=self.language) + + def save(self, *args, **kwargs): + # Check if the language is supported by speech-to-text AI model. + if self.language and self.language.lang_code not in CAPTIONS_LANGUAGES: + raise ValueError(f"The language is currently not supported by speech-to-text model.") + super(CaptionFile, self).save(*args, **kwargs) class CaptionCue(models.Model): diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 6931d5bdbb..e4579b83a9 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -3,7 +3,7 @@ import json import uuid -from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.models import CaptionCue, CaptionFile, Language from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import ( @@ -21,7 +21,7 @@ class SyncTestCase(SyncTestMixin, StudioAPITestCase): def caption_file_metadata(self): return { "file_id": uuid.uuid4().hex, - "language": "en", + "language": Language.objects.get(pk="en").pk, } @property @@ -30,27 +30,20 @@ def same_file_different_language_metadata(self): return [ { "file_id": id, - "language": "en", + "language": Language.objects.get(pk="en"), }, { "file_id": id, - "language": "ru", + "language": Language.objects.get(pk="ru"), }, ] - @property - def caption_file_db_metadata(self): - return { - "file_id": uuid.uuid4().hex, - "language": "en", - } - @property def caption_cue_metadata(self): return { "file": { "file_id": uuid.uuid4().hex, - "language": "en", + "language": Language.objects.get(pk="en").pk, }, "cue": { "text": "This is the beginning!", @@ -65,7 +58,6 @@ def setUp(self): self.user = testdata.user() self.channel.editors.add(self.user) - # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata @@ -85,18 +77,20 @@ def test_create_caption(self): try: caption_file_db = CaptionFile.objects.get( file_id=caption_file["file_id"], - language=caption_file["language"], + language_id=caption_file["language"], ) except CaptionFile.DoesNotExist: self.fail("caption file was not created") # Check the values of the object in the PostgreSQL self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) - self.assertEqual(caption_file_db.language, caption_file["language"]) + self.assertEqual(caption_file_db.language_id, caption_file["language"]) def test_delete_caption_file(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata + # Explicitly set language to model object to follow Django ORM conventions + caption_file['language'] = Language.objects.get(pk='en') caption_file_1 = CaptionFile(**caption_file) pk = caption_file_1.pk @@ -108,7 +102,7 @@ def test_delete_caption_file(self): with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file["file_id"], language=caption_file["language"] + file_id=caption_file["file_id"], language_id=caption_file["language"] ) def test_delete_file_with_same_file_id_different_language(self): @@ -132,11 +126,13 @@ def test_delete_file_with_same_file_id_different_language(self): with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file_2.file_id, language=caption_file_2.language + file_id=caption_file_2.file_id, language_id=caption_file_2.language ) def test_caption_file_serialization(self): metadata = self.caption_file_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['language'] = Language.objects.get(pk="en") caption_file = CaptionFile.objects.create(**metadata) serializer = CaptionFileSerializer(instance=caption_file) try: @@ -146,6 +142,8 @@ def test_caption_file_serialization(self): def test_caption_cue_serialization(self): metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update( @@ -166,6 +164,8 @@ def test_caption_cue_serialization(self): def test_create_caption_cue(self): self.client.force_authenticate(user=self.user) metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file_1 = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue["caption_file_id"] = caption_file_1.pk @@ -194,6 +194,8 @@ def test_create_caption_cue(self): def test_delete_caption_cue(self): self.client.force_authenticate(user=self.user) metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file_1 = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update({"caption_file": caption_file_1}) @@ -228,6 +230,8 @@ def test_delete_caption_cue(self): def test_update_caption_cue(self): self.client.force_authenticate(user=self.user) metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file_1 = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] @@ -280,6 +284,8 @@ def test_update_caption_cue(self): def test_invalid_caption_cue_data_serialization(self): metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update( diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 09550a7655..419605f0a5 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -2,7 +2,6 @@ AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES, - VIDEO_SUBTITLE, ) from rest_framework import serializers from rest_framework.permissions import IsAuthenticated @@ -26,6 +25,13 @@ def validate(self, attrs): return attrs def to_internal_value(self, data): + """ + Copies the caption_file_id from the request data + to the internal representation before validation. + + Without this, the caption_file_id would be lost + if validation fails, leading to errors. + """ caption_file_id = data.get("caption_file_id") value = super().to_internal_value(data) @@ -43,6 +49,12 @@ class Meta: @classmethod def id_attr(cls): + """ + Returns the primary key name for the model class. + + Checks Meta.update_lookup_field to allow customizable + primary key names. Falls back to using the default "id". + """ ModelClass = cls.Meta.model info = model_meta.get_field_info(ModelClass) return getattr(cls.Meta, "update_lookup_field", info.pk.name) @@ -63,13 +75,14 @@ class CaptionViewSet(ValuesViewset): def get_queryset(self): queryset = super().get_queryset() - contentnode_ids = self.request.GET.get("contentnode_id").split(',') + contentnode_ids = self.request.GET.get("contentnode_id") file_id = self.request.GET.get("file_id") language = self.request.GET.get("language") if contentnode_ids: + contentnode_ids = contentnode_ids.split(',') file_ids = File.objects.filter( - preset_id__in=[AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES, VIDEO_SUBTITLE], + preset_id__in=[AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES], contentnode_id__in=contentnode_ids, ).values_list("pk", flat=True) queryset = queryset.filter(file_id__in=file_ids) From a31895d872a7595842ca9902544d8aac2b3a85a2 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 1 Aug 2023 23:41:06 +0530 Subject: [PATCH 034/257] maybe this will break the --- .../channelEdit/vuex/caption/actions.js | 59 ++++ .../channelEdit/vuex/caption/mutations.js | 48 +++ .../tests/viewsets/test_caption.py | 302 ++++++++++++++++++ 3 files changed, 409 insertions(+) diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index e69de29bb2..53d5223333 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -0,0 +1,59 @@ +import { CaptionFile, CaptionCues } from 'shared/data/resources'; + +export async function loadCaptionFiles(commit, params) { + const captionFiles = await CaptionFile.where(params); + commit('ADD_CAPTIONFILES', { captionFiles, nodeIds: params.contentnode_id }); + return captionFiles; +} + +export async function loadCaptionCues({ commit }, { caption_file_id }) { + const cues = await CaptionCues.where({ caption_file_id }) + commit('ADD_CAPTIONCUES', cues); + return cues; +} + +export async function loadCaptions({ commit, rootGetters }, params) { + const isAIFeatureEnabled = rootGetters['currentChannel/isAIFeatureEnabled']; + if(!isAIFeatureEnabled) return; + + // If a new file is uploaded, the contentnode_id will be string + if(typeof params.contentnode_id === 'string') { + params.contentnode_id = [params.contentnode_id] + } + const nodeIdsToLoad = []; + for (const nodeId of params.contentnode_id) { + const node = rootGetters['contentNode/getContentNode'](nodeId); + if (node && (node.kind === 'video' || node.kind === 'audio')) { + nodeIdsToLoad.push(nodeId); // already in vuex + } else if(!node) { + nodeIdsToLoad.push(nodeId); // Assume that its audio/video + } + } + + const captionFiles = await loadCaptionFiles(commit, { + contentnode_id: nodeIdsToLoad + }); + + // If there is no Caption File for this contentnode + // Don't request for the cues + if(captionFiles.length === 0) return; + // TODO: call loadCaptionCues -> to be done after + // I finish saving captionFiles in indexedDB When + // CTA is called. So I have captions saved in the backend. +} + + +export async function addCaptionFile({ commit }, { file_id, language, nodeId }) { + const captionFile = { + file_id: file_id, + language: language + } + return CaptionFile.add(captionFile).then(id => { + captionFile.id = id; + console.log(captionFile, nodeId); + commit('ADD_CAPTIONFILE', { + captionFile, + nodeId + }); + }) +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js index e69de29bb2..969306dfa3 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js @@ -0,0 +1,48 @@ +import Vue from "vue"; + +/* Mutations for Caption File */ +export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { + if(!captionFile && !nodeId) return; + // Check if there is Map for the current nodeId + if(!state.captionFilesMap[nodeId]) { + Vue.set(state.captionFilesMap, nodeId, {}); + } + + // Check if the pk exists in the contentNode's object + if (!state.captionFilesMap[nodeId][captionFile.id]) { + // If it doesn't exist, create an empty object for that pk + Vue.set(state.captionFilesMap[nodeId], captionFile.id, {}); + } + + // Check if the file_id and language combination already exists + // const key = `${captionFile.file_id}_${captionFile.language}`; + // if(state.captionFilesMap[nodeId][captionFile.id]) { + // } + + // Finally, set the file_id and language for that pk + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'id', captionFile.id); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'file_id', captionFile.file_id); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'language', captionFile.language); +} + +export function ADD_CAPTIONFILES(state, { captionFiles, nodeIds }) { + captionFiles.forEach((captionFile, index) => { + const nodeId = nodeIds[index]; + ADD_CAPTIONFILE(state, { captionFile, nodeId }); + }); +} + +/* Mutations for Caption Cues */ +export function ADD_CUE(state, cue) { + // TODO: add some checks to Cue + + Vue.set(state.captionCuesMap, cue.id, cue); +} + +export function ADD_CAPTIONCUES(state, { data } = []) { + if (Array.isArray(data)) { // Workaround to fix TypeError: data.forEach + data.forEach(cue => { + ADD_CUE(state, cue); + }) + } +} diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index e69de29bb2..e4579b83a9 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -0,0 +1,302 @@ +from __future__ import absolute_import + +import json +import uuid + +from contentcuration.models import CaptionCue, CaptionFile, Language +from contentcuration.tests import testdata +from contentcuration.tests.base import StudioAPITestCase +from contentcuration.tests.viewsets.base import ( + SyncTestMixin, + generate_create_event, + generate_delete_event, + generate_update_event, +) +from contentcuration.viewsets.caption import CaptionCueSerializer, CaptionFileSerializer +from contentcuration.viewsets.sync.constants import CAPTION_CUES, CAPTION_FILE + + +class SyncTestCase(SyncTestMixin, StudioAPITestCase): + @property + def caption_file_metadata(self): + return { + "file_id": uuid.uuid4().hex, + "language": Language.objects.get(pk="en").pk, + } + + @property + def same_file_different_language_metadata(self): + id = uuid.uuid4().hex + return [ + { + "file_id": id, + "language": Language.objects.get(pk="en"), + }, + { + "file_id": id, + "language": Language.objects.get(pk="ru"), + }, + ] + + @property + def caption_cue_metadata(self): + return { + "file": { + "file_id": uuid.uuid4().hex, + "language": Language.objects.get(pk="en").pk, + }, + "cue": { + "text": "This is the beginning!", + "starttime": 0.0, + "endtime": 12.0, + }, + } + + def setUp(self): + super(SyncTestCase, self).setUp() + self.channel = testdata.channel() + self.user = testdata.user() + self.channel.editors.add(self.user) + + def test_create_caption(self): + self.client.force_authenticate(user=self.user) + caption_file = self.caption_file_metadata + + response = self.sync_changes( + [ + generate_create_event( + uuid.uuid4().hex, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + + try: + caption_file_db = CaptionFile.objects.get( + file_id=caption_file["file_id"], + language_id=caption_file["language"], + ) + except CaptionFile.DoesNotExist: + self.fail("caption file was not created") + + # Check the values of the object in the PostgreSQL + self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) + self.assertEqual(caption_file_db.language_id, caption_file["language"]) + + def test_delete_caption_file(self): + self.client.force_authenticate(user=self.user) + caption_file = self.caption_file_metadata + # Explicitly set language to model object to follow Django ORM conventions + caption_file['language'] = Language.objects.get(pk='en') + caption_file_1 = CaptionFile(**caption_file) + pk = caption_file_1.pk + + # Delete the caption file + response = self.sync_changes( + [generate_delete_event(pk, CAPTION_FILE, channel_id=self.channel.id)] + ) + self.assertEqual(response.status_code, 200, response.content) + + with self.assertRaises(CaptionFile.DoesNotExist): + caption_file_db = CaptionFile.objects.get( + file_id=caption_file["file_id"], language_id=caption_file["language"] + ) + + def test_delete_file_with_same_file_id_different_language(self): + self.client.force_authenticate(user=self.user) + obj = self.same_file_different_language_metadata + + caption_file_1 = CaptionFile.objects.create(**obj[0]) + caption_file_2 = CaptionFile.objects.create(**obj[1]) + + response = self.sync_changes( + [ + generate_delete_event( + caption_file_2.pk, + CAPTION_FILE, + channel_id=self.channel.id, + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + + with self.assertRaises(CaptionFile.DoesNotExist): + caption_file_db = CaptionFile.objects.get( + file_id=caption_file_2.file_id, language_id=caption_file_2.language + ) + + def test_caption_file_serialization(self): + metadata = self.caption_file_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['language'] = Language.objects.get(pk="en") + caption_file = CaptionFile.objects.create(**metadata) + serializer = CaptionFileSerializer(instance=caption_file) + try: + jd = json.dumps(serializer.data) # Try to serialize the data to JSON + except Exception as e: + self.fail(f"CaptionFile serialization failed. Error: {str(e)}") + + def test_caption_cue_serialization(self): + metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") + caption_file = CaptionFile.objects.create(**metadata["file"]) + caption_cue = metadata["cue"] + caption_cue.update( + { + "caption_file": caption_file, + } + ) + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + caption_cue_2 = CaptionCue.objects.create( + text="How are you?", starttime=2.0, endtime=3.0, caption_file=caption_file + ) + serializer = CaptionFileSerializer(instance=caption_file) + try: + json.dumps(serializer.data) # Try to serialize the data to JSON + except Exception as e: + self.fail(f"CaptionFile serialization failed. Error: {str(e)}") + + def test_create_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") + caption_file_1 = CaptionFile.objects.create(**metadata["file"]) + caption_cue = metadata["cue"] + caption_cue["caption_file_id"] = caption_file_1.pk + + response = self.sync_changes( + [ + generate_create_event( + uuid.uuid4(), + CAPTION_CUES, + caption_cue, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue["text"], + starttime=caption_cue["starttime"], + endtime=caption_cue["endtime"], + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + def test_delete_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") + caption_file_1 = CaptionFile.objects.create(**metadata["file"]) + caption_cue = metadata["cue"] + caption_cue.update({"caption_file": caption_file_1}) + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue["text"], + starttime=caption_cue["starttime"], + endtime=caption_cue["endtime"], + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + # Delete the caption Cue that we just created + response = self.sync_changes( + [ + generate_delete_event( + caption_cue_db.pk, CAPTION_CUES, channel_id=self.channel.id + ) + ] + ) + self.assertEqual(response.status_code, 200, response.content) + + caption_cue_db_exists = CaptionCue.objects.filter( + text=caption_cue["text"], + starttime=caption_cue["starttime"], + endtime=caption_cue["endtime"], + ).exists() + if caption_cue_db_exists: + self.fail("Caption Cue still exists!") + + def test_update_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") + caption_file_1 = CaptionFile.objects.create(**metadata["file"]) + + caption_cue = metadata["cue"] + caption_cue.update({"caption_file": caption_file_1}) + + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue["text"], + starttime=caption_cue["starttime"], + endtime=caption_cue["endtime"], + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + # Update the cue + pk = caption_cue_1.pk + new_text = "Yo" + new_starttime = 10 + new_endtime = 20 + + response = self.sync_changes( + [ + generate_update_event( + pk, + CAPTION_CUES, + { + "text": new_text, + "starttime": new_starttime, + "endtime": new_endtime, + "caption_file_id": caption_file_1.pk, + }, + channel_id=self.channel.id, + ) + ] + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual( + CaptionCue.objects.get(id=pk).text, + new_text, + ) + self.assertEqual( + CaptionCue.objects.get(id=pk).starttime, + new_starttime, + ) + self.assertEqual( + CaptionCue.objects.get(id=pk).endtime, + new_endtime, + ) + + def test_invalid_caption_cue_data_serialization(self): + metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") + caption_file = CaptionFile.objects.create(**metadata["file"]) + caption_cue = metadata["cue"] + caption_cue.update( + { + "starttime": float(20), + "endtime": float(10), + "caption_file": caption_file, + } + ) + serializer = CaptionCueSerializer(data=caption_cue) + assert not serializer.is_valid() + errors = serializer.errors + assert "non_field_errors" in errors + assert str(errors["non_field_errors"][0]) == "The cue must finish after start." From 4f88131ae120a7eac52daacd74dda8258b36b48c Mon Sep 17 00:00:00 2001 From: akash5100 Date: Wed, 2 Aug 2023 00:27:10 +0530 Subject: [PATCH 035/257] fixs merge conflict --- .../constants/transcription_languages.py | 1 - .../channelEdit/components/edit/EditList.vue | 11 ---- .../channelEdit/components/edit/EditModal.vue | 5 -- .../frontend/shared/data/resources.js | 57 ------------------- contentcuration/contentcuration/models.py | 1 - 5 files changed, 75 deletions(-) diff --git a/contentcuration/contentcuration/constants/transcription_languages.py b/contentcuration/contentcuration/constants/transcription_languages.py index 9ed10af703..7eeefca57c 100644 --- a/contentcuration/contentcuration/constants/transcription_languages.py +++ b/contentcuration/contentcuration/constants/transcription_languages.py @@ -134,4 +134,3 @@ def create_captions_languages(): return list(kolibri_set.intersection(model_set)) CAPTIONS_LANGUAGES = create_captions_languages() - diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 91401ed102..dc55fe2485 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -22,7 +22,6 @@ + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue index f3ce855b2e..5f221e9b72 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue @@ -62,6 +62,16 @@ {{ relatedResourcesCount }} + + + + {{ $tr(tabs.CAPTIONS) }} + @@ -82,6 +92,7 @@ + + + + + @@ -104,6 +120,7 @@ import { TabNames } from '../../constants'; import AssessmentTab from '../../components/AssessmentTab/AssessmentTab'; + import CaptionsEditor from '../../components/CaptionsEditor/CaptionsEditor' import RelatedResourcesTab from '../../components/RelatedResourcesTab/RelatedResourcesTab'; import DetailsTabView from './DetailsTabView'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -113,11 +130,12 @@ export default { name: 'EditView', components: { - DetailsTabView, - AssessmentTab, - RelatedResourcesTab, - Tabs, - ToolBar, + AssessmentTab, + CaptionsEditor, + DetailsTabView, + RelatedResourcesTab, + Tabs, + ToolBar, }, props: { nodeIds: { @@ -143,6 +161,7 @@ 'getImmediateRelatedResourcesCount', ]), ...mapGetters('assessmentItem', ['getAssessmentItemsAreValid', 'getAssessmentItemsCount']), + ...mapGetters('currentChannel', ['isAIFeatureEnabled']), firstNode() { return this.nodes.length ? this.nodes[0] : null; }, @@ -167,6 +186,14 @@ showRelatedResourcesTab() { return this.oneSelected && this.firstNode && this.firstNode.kind !== 'topic'; }, + showCaptions() { + return ( + this.oneSelected && + this.firstNode && + (this.firstNode.kind === 'video' || this.firstNode.kind === 'audio') && + this.isAIFeatureEnabled + ) + }, countText() { const totals = reduce( this.nodes, @@ -260,6 +287,8 @@ questions: 'Questions', /** @see TabNames.RELATED */ related: 'Related', + /** @see TabNames.CAPTIONS */ + captions: 'Captions', /* eslint-enable kolibri/vue-no-unused-translations */ noItemsToEditText: 'Please select resources or folders to edit', invalidFieldsToolTip: 'Some required information is missing', diff --git a/contentcuration/contentcuration/frontend/channelEdit/constants.js b/contentcuration/contentcuration/frontend/channelEdit/constants.js index 6512e9e9b4..baa7c25551 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/constants.js +++ b/contentcuration/contentcuration/frontend/channelEdit/constants.js @@ -55,6 +55,7 @@ export const TabNames = { PREVIEW: 'preview', QUESTIONS: 'questions', RELATED: 'related', + CAPTIONS: 'captions' }; export const modes = { diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js index ead653c2e4..3aeefe506b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js @@ -14,6 +14,10 @@ export function canEdit(state, getters, rootState, rootGetters) { ); } +export function isAIFeatureEnabled(state, getters, rootState, rootGetters) { + return rootGetters.featureFlags.ai_feature || false; +} + // Allow some extra actions for ricecooker channels export function canManage(state, getters, rootState, rootGetters) { return getters.currentChannel && (getters.currentChannel.edit || rootGetters.isAdmin); diff --git a/contentcuration/contentcuration/frontend/shared/data/constants.js b/contentcuration/contentcuration/frontend/shared/data/constants.js index 709b251d1f..bc5acc4eea 100644 --- a/contentcuration/contentcuration/frontend/shared/data/constants.js +++ b/contentcuration/contentcuration/frontend/shared/data/constants.js @@ -46,6 +46,7 @@ export const TABLE_NAMES = { TASK: 'task', CHANGES_TABLE, BOOKMARK: 'bookmark', + CAPTION: 'caption' }; /** diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 4a7101f307..399489f9a2 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1017,6 +1017,10 @@ export const Bookmark = new Resource({ getUserId: getUserIdFromStore, }); +export const Caption = new Resource({ + // TODO +}) + export const Channel = new Resource({ tableName: TABLE_NAMES.CHANNEL, urlName: 'channel', diff --git a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py b/contentcuration/contentcuration/migrations/0143_caption.py similarity index 53% rename from contentcuration/contentcuration/migrations/0143_generatedcaptions.py rename to contentcuration/contentcuration/migrations/0143_caption.py index 0502d7e8bc..3d6e0e769c 100644 --- a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py +++ b/contentcuration/contentcuration/migrations/0143_caption.py @@ -1,6 +1,8 @@ -# Generated by Django 3.2.14 on 2023-05-23 11:00 +# Generated by Django 3.2.14 on 2023-06-15 06:13 +import contentcuration.models from django.db import migrations, models +import uuid class Migration(migrations.Migration): @@ -11,10 +13,10 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='GeneratedCaptions', + name='Caption', fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('generated_captions', models.JSONField()), + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('caption', models.JSONField()), ('language', models.CharField(max_length=10)), ], ), diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index c1c1c8f7c6..83c2badce9 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2060,10 +2060,14 @@ def __str__(self): return self.ietf_name() -class GeneratedCaptions(models.Model): - id = models.AutoField(primary_key=True) - generated_captions = models.JSONField() +class Caption(models.Model): + """ + Model to store captions and support intermediary changes + """ + id = UUIDField(primary_key=True, default=uuid.uuid4) + caption = models.JSONField() language = models.CharField(max_length=10) + # file_id = models.CharField(unique=True, max_length=32) ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index ad3bb991c4..581505918f 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,8 +32,8 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet -from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet +from contentcuration.viewsets.caption import CaptionViewSet from contentcuration.viewsets.channel import AdminChannelViewSet from contentcuration.viewsets.channel import CatalogViewSet from contentcuration.viewsets.channel import ChannelViewSet @@ -56,7 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") -router.register(r'captions', CaptionViewSet) +router.register(r'caption', CaptionViewSet) router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py new file mode 100644 index 0000000000..2dd2062bc7 --- /dev/null +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -0,0 +1,40 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from contentcuration.models import Caption + + +class CaptionSerializer(serializers.ModelSerializer): + class Meta: + model = Caption + fields = ["id", "caption", "language"] + + +class CaptionViewSet(ModelViewSet): + queryset = Caption.objects.all() + serializer_class = CaptionSerializer + + def create(self, request): + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + self.perform_create(serializer=serializer) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, headers=headers, status=status.HTTP_201_CREATED + ) + + def update(self, request, pk=None): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + if not serializer.is_valid(raise_exception=True): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py deleted file mode 100644 index 84a5e3981c..0000000000 --- a/contentcuration/contentcuration/viewsets/captions.py +++ /dev/null @@ -1,27 +0,0 @@ -from rest_framework import serializers, status -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from contentcuration.models import GeneratedCaptions - - -class GeneratedCaptionsSerializer(serializers.ModelSerializer): - class Meta: - model = GeneratedCaptions - fields = ['id', 'generated_captions', 'language'] - -class CaptionViewSet(ModelViewSet): - queryset = GeneratedCaptions.objects.all() - serializer_class = GeneratedCaptionsSerializer - - def create(self, request): - # handles the creation operation and return serialized data - pass - - def update(self, request): - # handles the updating of an existing `GeneratedCaption` instance. - pass - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index 44e3861050..879c67e123 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,7 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet -from contentcuration.viewsets.captions import CaptionViewSet +from contentcuration.viewsets.caption import CaptionViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index ffc3227873..1576953b50 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,7 +22,7 @@ # Client-side table constants BOOKMARK = "bookmark" -CAPTION = "captions" +CAPTION = "caption" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" From 471814d21a805a1fed9c678e7e54b67cb9e01c80 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:29:13 +0530 Subject: [PATCH 038/257] Adds Sync API tests for CaptionFile ViewSet --- .../constants/transcription_languages.py | 106 +++++++++++++ .../migrations/0143_caption.py | 23 --- .../migrations/0143_captioncue_captionfile.py | 37 +++++ contentcuration/contentcuration/models.py | 41 ++++- .../tests/viewsets/test_caption.py | 147 ++++++++++++++++++ .../contentcuration/viewsets/caption.py | 99 ++++++++---- .../contentcuration/viewsets/sync/base.py | 8 +- .../viewsets/sync/constants.py | 6 +- 8 files changed, 403 insertions(+), 64 deletions(-) create mode 100644 contentcuration/contentcuration/constants/transcription_languages.py delete mode 100644 contentcuration/contentcuration/migrations/0143_caption.py create mode 100644 contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py create mode 100644 contentcuration/contentcuration/tests/viewsets/test_caption.py diff --git a/contentcuration/contentcuration/constants/transcription_languages.py b/contentcuration/contentcuration/constants/transcription_languages.py new file mode 100644 index 0000000000..753c91e7d3 --- /dev/null +++ b/contentcuration/contentcuration/constants/transcription_languages.py @@ -0,0 +1,106 @@ +# This file contains a list of transcription languages. +# The list is in the format of (language code, language name). +# For example, the first element in the list is ('en', 'english'). + + +CAPTIONS_LANGUAGES = [ + ("en", "english"), + ("zh", "chinese"), + ("de", "german"), + ("es", "spanish"), + ("ru", "russian"), + ("ko", "korean"), + ("fr", "french"), + ("ja", "japanese"), + ("pt", "portuguese"), + ("tr", "turkish"), + ("pl", "polish"), + ("ca", "catalan"), + ("nl", "dutch"), + ("ar", "arabic"), + ("sv", "swedish"), + ("it", "italian"), + ("id", "indonesian"), + ("hi", "hindi"), + ("fi", "finnish"), + ("vi", "vietnamese"), + ("he", "hebrew"), + ("uk", "ukrainian"), + ("el", "greek"), + ("ms", "malay"), + ("cs", "czech"), + ("ro", "romanian"), + ("da", "danish"), + ("hu", "hungarian"), + ("ta", "tamil"), + ("no", "norwegian"), + ("th", "thai"), + ("ur", "urdu"), + ("hr", "croatian"), + ("bg", "bulgarian"), + ("lt", "lithuanian"), + ("la", "latin"), + ("mi", "maori"), + ("ml", "malayalam"), + ("cy", "welsh"), + ("sk", "slovak"), + ("te", "telugu"), + ("fa", "persian"), + ("lv", "latvian"), + ("bn", "bengali"), + ("sr", "serbian"), + ("az", "azerbaijani"), + ("sl", "slovenian"), + ("kn", "kannada"), + ("et", "estonian"), + ("mk", "macedonian"), + ("br", "breton"), + ("eu", "basque"), + ("is", "icelandic"), + ("hy", "armenian"), + ("ne", "nepali"), + ("mn", "mongolian"), + ("bs", "bosnian"), + ("kk", "kazakh"), + ("sq", "albanian"), + ("sw", "swahili"), + ("gl", "galician"), + ("mr", "marathi"), + ("pa", "punjabi"), + ("si", "sinhala"), + ("km", "khmer"), + ("sn", "shona"), + ("yo", "yoruba"), + ("so", "somali"), + ("af", "afrikaans"), + ("oc", "occitan"), + ("ka", "georgian"), + ("be", "belarusian"), + ("tg", "tajik"), + ("sd", "sindhi"), + ("gu", "gujarati"), + ("am", "amharic"), + ("yi", "yiddish"), + ("lo", "lao"), + ("uz", "uzbek"), + ("fo", "faroese"), + ("ht", "haitian creole"), + ("ps", "pashto"), + ("tk", "turkmen"), + ("nn", "nynorsk"), + ("mt", "maltese"), + ("sa", "sanskrit"), + ("lb", "luxembourgish"), + ("my", "myanmar"), + ("bo", "tibetan"), + ("tl", "tagalog"), + ("mg", "malagasy"), + ("as", "assamese"), + ("tt", "tatar"), + ("haw", "hawaiian"), + ("ln", "lingala"), + ("ha", "hausa"), + ("ba", "bashkir"), + ("jw", "javanese"), + ("su", "sundanese"), +] \ No newline at end of file diff --git a/contentcuration/contentcuration/migrations/0143_caption.py b/contentcuration/contentcuration/migrations/0143_caption.py deleted file mode 100644 index 3d6e0e769c..0000000000 --- a/contentcuration/contentcuration/migrations/0143_caption.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-15 06:13 - -import contentcuration.models -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='Caption', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('caption', models.JSONField()), - ('language', models.CharField(max_length=10)), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py new file mode 100644 index 0000000000..b8176d6a02 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-06-26 17:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 83c2badce9..71dcc0e12b 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,6 +2,7 @@ import json import logging import os +from typing import Any import urllib.parse import uuid from datetime import datetime @@ -68,6 +69,7 @@ from contentcuration.constants import channel_history from contentcuration.constants import completion_criteria from contentcuration.constants import user_history +from contentcuration.constants.transcription_languages import CAPTIONS_LANGUAGES from contentcuration.constants.contentnode import kind_activity_map from contentcuration.db.models.expressions import Array from contentcuration.db.models.functions import ArrayRemove @@ -2060,15 +2062,42 @@ def __str__(self): return self.ietf_name() -class Caption(models.Model): +class CaptionFile(models.Model): """ - Model to store captions and support intermediary changes + Represents a caption file record. + + - file_id: The identifier of related file in Google Cloud Storage. + - language: The language of the caption file. + """ + id = UUIDField(primary_key=True, default=uuid.uuid4) + file_id = UUIDField(default=uuid.uuid4, max_length=36) + language = models.CharField(choices=CAPTIONS_LANGUAGES, max_length=3) + + class Meta: + unique_together = ['file_id', 'language'] + + def __str__(self): + return "{file_id} -> {language}".format(file_id=self.file_id, language=self.language) + + +class CaptionCue(models.Model): + """ + Represents a caption cue in a VTT file. + + - text: The caption text. + - starttime: The start time of the cue in seconds. + - endtime: The end time of the cue in seconds. + - caption_file (Foreign Key): The related caption file. """ id = UUIDField(primary_key=True, default=uuid.uuid4) - caption = models.JSONField() - language = models.CharField(max_length=10) - # file_id = models.CharField(unique=True, max_length=32) - + text = models.TextField(null=False) + starttime = models.FloatField(null=False) + endtime = models.FloatField(null=False) + caption_file = models.ForeignKey(CaptionFile, related_name="caption_cue", on_delete=models.CASCADE) + + def __str__(self): + return "text: {text}, start_time: {starttime}, end_time: {endtime}".format(text=self.text, starttime=self.starttime, endtime=self.endtime) + ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py new file mode 100644 index 0000000000..07ffe9dfa0 --- /dev/null +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -0,0 +1,147 @@ +from __future__ import absolute_import + +import uuid + +from django.urls import reverse + +from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.tests.base import StudioAPITestCase +from contentcuration.tests import testdata +from contentcuration.tests.viewsets.base import SyncTestMixin +from contentcuration.tests.viewsets.base import generate_create_event +from contentcuration.tests.viewsets.base import generate_update_event +from contentcuration.tests.viewsets.base import generate_delete_event +from contentcuration.viewsets.sync.constants import CAPTION_FILE + +# class CRUDTestCase(StudioAPITestCase): + +class SyncTestCase(SyncTestMixin, StudioAPITestCase): + + @property + def caption_file_metadata(self): + return { + "file_id": uuid.uuid4().hex, + "language": "en", + } + + @property + def same_file_different_language_metadata(self): + id = uuid.uuid4().hex + return [ + { + "file_id": id, + "language": "en", + }, + { + "file_id": id, + "language": "ru", + } + ] + + @property + def caption_file_db_metadata(self): + return { + "file_id": uuid.uuid4().hex, + "language": "en", + } + + + def setUp(self): + super(SyncTestCase, self).setUp() + self.channel = testdata.channel() + self.user = testdata.user() + self.channel.editors.add(self.user) + + + def test_create_caption(self): + self.client.force_authenticate(user=self.user) + caption_file = self.caption_file_metadata + + response = self.sync_changes( + [ + generate_create_event( + uuid.uuid4().hex, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + + try: + caption_file_db = CaptionFile.objects.get( + file_id=caption_file["file_id"], + language=caption_file["language"], + ) + except CaptionFile.DoesNotExist: + self.fail("caption file was not created") + + # Check the values of the object in the PostgreSQL + self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) + self.assertEqual(caption_file_db.language, caption_file["language"]) + + def test_delete_caption_file(self): + self.client.force_authenticate(user=self.user) + caption_file = self.caption_file_metadata + pk = uuid.uuid4().hex + response = self.sync_changes( + [ + generate_create_event( + pk, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id + ) + ] + ) + self.assertEqual(response.status_code, 200, response.content) + + # Delete the caption file + response = self.sync_changes( + [ + generate_delete_event( + pk, + CAPTION_FILE, + channel_id=self.channel.id + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + + with self.assertRaises(CaptionFile.DoesNotExist): + caption_file_db = CaptionFile.objects.get( + file_id=caption_file['file_id'], + language=caption_file['language'] + ) + + + def test_delete_file_with_same_file_id_different_language(self): + self.client.force_authenticate(user=self.user) + obj = self.same_file_different_language_metadata + + caption_file_1 = CaptionFile.objects.create( + **obj[0] + ) + caption_file_2 = CaptionFile.objects.create( + **obj[1] + ) + + response = self.sync_changes( + [ + generate_delete_event( + caption_file_2.pk, + CAPTION_FILE, + channel_id=self.channel.id, + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + + with self.assertRaises(CaptionFile.DoesNotExist): + caption_file_db = CaptionFile.objects.get( + file_id=caption_file_2.file_id, + language=caption_file_2.language + ) diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 2dd2062bc7..1a62b191d5 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,40 +1,79 @@ from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from contentcuration.models import Caption +from contentcuration.models import CaptionCue +from contentcuration.models import CaptionFile +from contentcuration.viewsets.base import ValuesViewset + +from contentcuration.viewsets.sync.utils import log_sync_exception + +from django.core.exceptions import ObjectDoesNotExist + + +""" +[x] create file - POST /api/caption?file_id=..&language=.. +[x] delete file - DELETE /api/caption?file_id=..&language=.. + +[] create file cue - POST /api/caption/cue?file_id=..&language=.. +[] update file cue - PATCH /api/caption/cue?file_id=..&language=..&cue_id=.. +[] delete file cue - DELETE /api/caption/cue?file_id=..&language=..&cue_id=.. + +[] get the file cues - GET /api/caption?file_id=..&language=.. +""" + + +class CueSerializer(serializers.ModelSerializer): + class Meta: + model = CaptionCue + fields = ["text", "starttime", "endtime"] class CaptionSerializer(serializers.ModelSerializer): + caption_cue = CueSerializer(many=True, required=False) + class Meta: - model = Caption - fields = ["id", "caption", "language"] + model = CaptionFile + fields = ["file_id", "language", "caption_cue"] -class CaptionViewSet(ModelViewSet): - queryset = Caption.objects.all() +class CaptionViewSet(ValuesViewset): + # Handles operations for the CaptionFile model. + queryset = CaptionFile.objects.prefetch_related("caption_cue") + permission_classes = [IsAuthenticated] serializer_class = CaptionSerializer + values = ("file_id", "language", "caption_cue") + + field_map = {"file": "file_id", "language": "language"} + + def delete_from_changes(self, changes): + errors = [] + queryset = self.get_edit_queryset().order_by() + for change in changes: + try: + instance = queryset.filter(**dict(self.values_from_key(change["key"]))) + + self.perform_destroy(instance) + except ObjectDoesNotExist: + # If the object already doesn't exist, as far as the user is concerned + # job done! + pass + except Exception as e: + log_sync_exception(e, user=self.request.user, change=change) + change["errors"] = [str(e)] + errors.append(change) + return errors + + +class CaptionCueViewSet(ValuesViewset): + # Handles operations for the CaptionCue model. + queryset = CaptionCue.objects.all() + permission_classes = [IsAuthenticated] + serializer_class = CueSerializer + values = ("text", "starttime", "endtime") - def create(self, request): - serializer = self.get_serializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - self.perform_create(serializer=serializer) - headers = self.get_success_headers(serializer.data) - return Response( - serializer.data, headers=headers, status=status.HTTP_201_CREATED - ) - - def update(self, request, pk=None): - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=True) - if not serializer.is_valid(raise_exception=True): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) + field_map = { + "text": "text", + "start_time": "starttime", + "end_time": "endtime", + } + # Add caption file in field_map? diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index 879c67e123..7606853bcc 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,7 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet -from contentcuration.viewsets.caption import CaptionViewSet +from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet @@ -15,7 +15,8 @@ from contentcuration.viewsets.invitation import InvitationViewSet from contentcuration.viewsets.sync.constants import ASSESSMENTITEM from contentcuration.viewsets.sync.constants import BOOKMARK -from contentcuration.viewsets.sync.constants import CAPTION +from contentcuration.viewsets.sync.constants import CAPTION_CUES +from contentcuration.viewsets.sync.constants import CAPTION_FILE from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import CHANNELSET from contentcuration.viewsets.sync.constants import CLIPBOARD @@ -75,7 +76,8 @@ def __init__(self, change_type, viewset_class): (EDITOR_M2M, ChannelUserViewSet), (VIEWER_M2M, ChannelUserViewSet), (SAVEDSEARCH, SavedSearchViewSet), - (CAPTION, CaptionViewSet), + (CAPTION_FILE, CaptionViewSet), + (CAPTION_CUES, CaptionCueViewSet), ] ) diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index 1576953b50..6ad7305c6c 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,7 +22,8 @@ # Client-side table constants BOOKMARK = "bookmark" -CAPTION = "caption" +CAPTION_FILE = "caption_file" +CAPTION_CUES = "caption_cues" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" @@ -40,7 +41,8 @@ ALL_TABLES = set( [ BOOKMARK, - CAPTION, + CAPTION_FILE, + CAPTION_CUES, CHANNEL, CLIPBOARD, CONTENTNODE, From 833c1bbb7941dc44b13f24393921a938e0dd10fd Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:33:46 +0530 Subject: [PATCH 039/257] Removes unnecessary imports --- contentcuration/contentcuration/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 71dcc0e12b..0b427ff669 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,7 +2,6 @@ import json import logging import os -from typing import Any import urllib.parse import uuid from datetime import datetime From 215f9c0acaea92e9057db13eca3920a371238e81 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:35:36 +0530 Subject: [PATCH 040/257] Fixes text formatting --- .../tests/viewsets/test_caption.py | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 07ffe9dfa0..4e6eb45132 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -15,8 +15,8 @@ # class CRUDTestCase(StudioAPITestCase): -class SyncTestCase(SyncTestMixin, StudioAPITestCase): +class SyncTestCase(SyncTestMixin, StudioAPITestCase): @property def caption_file_metadata(self): return { @@ -35,7 +35,7 @@ def same_file_different_language_metadata(self): { "file_id": id, "language": "ru", - } + }, ] @property @@ -45,18 +45,16 @@ def caption_file_db_metadata(self): "language": "en", } - def setUp(self): super(SyncTestCase, self).setUp() self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata - + response = self.sync_changes( [ generate_create_event( @@ -88,10 +86,7 @@ def test_delete_caption_file(self): response = self.sync_changes( [ generate_create_event( - pk, - CAPTION_FILE, - caption_file, - channel_id=self.channel.id + pk, CAPTION_FILE, caption_file, channel_id=self.channel.id ) ] ) @@ -99,34 +94,22 @@ def test_delete_caption_file(self): # Delete the caption file response = self.sync_changes( - [ - generate_delete_event( - pk, - CAPTION_FILE, - channel_id=self.channel.id - ) - ] + [generate_delete_event(pk, CAPTION_FILE, channel_id=self.channel.id)] ) self.assertEqual(response.status_code, 200, response.content) with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file['file_id'], - language=caption_file['language'] + file_id=caption_file["file_id"], language=caption_file["language"] ) - def test_delete_file_with_same_file_id_different_language(self): self.client.force_authenticate(user=self.user) obj = self.same_file_different_language_metadata - caption_file_1 = CaptionFile.objects.create( - **obj[0] - ) - caption_file_2 = CaptionFile.objects.create( - **obj[1] - ) + caption_file_1 = CaptionFile.objects.create(**obj[0]) + caption_file_2 = CaptionFile.objects.create(**obj[1]) response = self.sync_changes( [ @@ -142,6 +125,5 @@ def test_delete_file_with_same_file_id_different_language(self): with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file_2.file_id, - language=caption_file_2.language + file_id=caption_file_2.file_id, language=caption_file_2.language ) From c34e7a9c8156e7ca2b01d5c88265d3f548ac3ab2 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:11:16 +0530 Subject: [PATCH 041/257] Creating CaptionCue with generate_create_event fails --- .../CaptionsEditor/CaptionsEditor.vue | 2 +- .../tests/viewsets/test_caption.py | 115 +++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 33f25de838..531dc7d90f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -16,4 +16,4 @@ \ No newline at end of file + diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 4e6eb45132..efd2bddc42 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -2,16 +2,17 @@ import uuid -from django.urls import reverse +import json from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.viewsets.caption import CaptionFileSerializer from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests import testdata from contentcuration.tests.viewsets.base import SyncTestMixin from contentcuration.tests.viewsets.base import generate_create_event from contentcuration.tests.viewsets.base import generate_update_event from contentcuration.tests.viewsets.base import generate_delete_event -from contentcuration.viewsets.sync.constants import CAPTION_FILE +from contentcuration.viewsets.sync.constants import CAPTION_FILE, CAPTION_CUES # class CRUDTestCase(StudioAPITestCase): @@ -45,12 +46,26 @@ def caption_file_db_metadata(self): "language": "en", } + @property + def caption_cue_metadata(self): + return { + "file": { + "file_id": uuid.uuid4().hex, + "language": "en", + }, + "cue": { + "text": "This is the beginning!", + "starttime": 0.0, + "endtime": 12.0, + }, + } + def setUp(self): super(SyncTestCase, self).setUp() self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - + # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata @@ -127,3 +142,97 @@ def test_delete_file_with_same_file_id_different_language(self): caption_file_db = CaptionFile.objects.get( file_id=caption_file_2.file_id, language=caption_file_2.language ) + + def test_caption_file_serialization(self): + metadata = self.caption_file_metadata + caption_file = CaptionFile.objects.create(**metadata) + serializer = CaptionFileSerializer(instance=caption_file) + try: + jd = json.dumps(serializer.data) # Try to serialize the data to JSON + except Exception as e: + self.fail(f"CaptionFile serialization failed. Error: {str(e)}") + + def test_caption_cue_serialization(self): + metadata = self.caption_cue_metadata + caption_file = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file, + }) + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + caption_cue_2 = CaptionCue.objects.create( + text='How are you?', + starttime=2.0, + endtime=3.0, + caption_file=caption_file + ) + serializer = CaptionFileSerializer(instance=caption_file) + try: + json.dumps(serializer.data) # Try to serialize the data to JSON + except Exception as e: + self.fail(f"CaptionFile serialization failed. Error: {str(e)}") + + def test_create_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + caption_file_1 = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file_1, + }) + # This works: caption_cue_1 = CaptionCue.objects.create(**caption_cue) + response = self.sync_changes( + [ + generate_create_event( + uuid.uuid4(), + CAPTION_CUES, + caption_cue, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + def test_delete_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + caption_file_1 = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file_1, + }) + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + # Delete the caption Cue that we just created + response = self.sync_changes( + [generate_delete_event(caption_cue_db.pk , CAPTION_CUES, channel_id=self.channel.id)] + ) + self.assertEqual(response.status_code, 200, response.content) + + caption_cue_db_exists = CaptionCue.objects.filter( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ).exists() + if caption_cue_db_exists: + self.fail("Caption Cue still exists!") + + def test_update_caption_cue(self): + pass From a6d1840f03b33fa6ddc950b269db1e92abcdd161 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:46:25 +0530 Subject: [PATCH 042/257] Add failing test for CaptionFile JSON serialization --- contentcuration/contentcuration/urls.py | 2 +- .../contentcuration/viewsets/caption.py | 37 ++++++------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 581505918f..763db0f696 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -56,7 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") -router.register(r'caption', CaptionViewSet) +router.register(r'captions', CaptionViewSet, basename="captions") router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 1a62b191d5..55eb822976 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,35 +1,19 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.http import JsonResponse from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from contentcuration.models import CaptionCue -from contentcuration.models import CaptionFile +from contentcuration.models import CaptionCue, CaptionFile from contentcuration.viewsets.base import ValuesViewset - from contentcuration.viewsets.sync.utils import log_sync_exception -from django.core.exceptions import ObjectDoesNotExist - - -""" -[x] create file - POST /api/caption?file_id=..&language=.. -[x] delete file - DELETE /api/caption?file_id=..&language=.. - -[] create file cue - POST /api/caption/cue?file_id=..&language=.. -[] update file cue - PATCH /api/caption/cue?file_id=..&language=..&cue_id=.. -[] delete file cue - DELETE /api/caption/cue?file_id=..&language=..&cue_id=.. - -[] get the file cues - GET /api/caption?file_id=..&language=.. -""" - - -class CueSerializer(serializers.ModelSerializer): +class CaptionCueSerializer(serializers.ModelSerializer): class Meta: model = CaptionCue fields = ["text", "starttime", "endtime"] - -class CaptionSerializer(serializers.ModelSerializer): - caption_cue = CueSerializer(many=True, required=False) +class CaptionFileSerializer(serializers.ModelSerializer): + caption_cue = CaptionCueSerializer(many=True, required=False) class Meta: model = CaptionFile @@ -40,10 +24,13 @@ class CaptionViewSet(ValuesViewset): # Handles operations for the CaptionFile model. queryset = CaptionFile.objects.prefetch_related("caption_cue") permission_classes = [IsAuthenticated] - serializer_class = CaptionSerializer + serializer_class = CaptionFileSerializer values = ("file_id", "language", "caption_cue") - field_map = {"file": "file_id", "language": "language"} + field_map = { + "file": "file_id", + "language": "language" + } def delete_from_changes(self, changes): errors = [] @@ -68,7 +55,7 @@ class CaptionCueViewSet(ValuesViewset): # Handles operations for the CaptionCue model. queryset = CaptionCue.objects.all() permission_classes = [IsAuthenticated] - serializer_class = CueSerializer + serializer_class = CaptionCueSerializer values = ("text", "starttime", "endtime") field_map = { From b102490b724a1021b06542bdcba1402bfa300a17 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 7 Jul 2023 13:12:04 +0530 Subject: [PATCH 043/257] Adds caption editor components, updated IndexedDB Resource --- .../CaptionsEditor/CaptionsEditor.vue | 15 +- .../frontend/channelEdit/constants.js | 2 +- .../channelEdit/vuex/caption/actions.js | 12 ++ .../channelEdit/vuex/caption/getters.js | 11 ++ .../channelEdit/vuex/caption/index.js | 26 +++ .../channelEdit/vuex/caption/mutations.js | 12 ++ .../frontend/shared/data/constants.js | 3 +- .../frontend/shared/data/resources.js | 17 +- contentcuration/contentcuration/models.py | 3 +- .../tests/viewsets/test_caption.py | 168 +++++++++++++----- contentcuration/contentcuration/urls.py | 3 +- .../contentcuration/viewsets/caption.py | 47 ++++- 12 files changed, 252 insertions(+), 67 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 531dc7d90f..4efd617226 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -1,17 +1,24 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue new file mode 100644 index 0000000000..ef7322e50d --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 4cbf88eadd..91401ed102 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -25,7 +25,6 @@ import { mapGetters, mapActions } from 'vuex'; import EditListItem from './EditListItem'; import Checkbox from 'shared/views/form/Checkbox'; - import { loadCaption } from '../../utils'; export default { name: 'EditList', @@ -44,16 +43,15 @@ }, }, watch: { - // watch the selected contentnode + // watch the selected contentnode to load captions value(newValue) { - if (this.isAIFeatureEnabled && newValue.length === 1) { - const nodeId = newValue[0]; - loadCaption([nodeId], this.loadCaptionFiles, this.loadCaptionCues); + if (newValue.length === 1) { + const nodeId = newValue[0]; // we dispatch action only when 1 node is selected + this.loadCaptions({ contentnode_id: nodeId }) } } }, computed: { - ...mapGetters('currentChannel', ['isAIFeatureEnabled']), selected: { get() { return this.value; @@ -77,7 +75,7 @@ }, }, methods: { - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), handleRemoved(nodeId) { const nodeIds = this.$route.params.detailNodeIds.split(',').filter(id => id !== nodeId); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 7547118227..09973325a5 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -197,7 +197,6 @@ import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; import { DELAYED_VALIDATION } from 'shared/constants'; - import { loadCaption } from '../../utils'; const CHECK_STORAGE_INTERVAL = 10000; @@ -251,7 +250,7 @@ computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), - ...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'isAIFeatureEnabled']), + ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), ...mapGetters('file', ['contentNodesAreUploading', 'getContentNodeFiles']), ...mapState({ online: state => state.connection.online, @@ -307,11 +306,6 @@ return this.nodeIds.filter(id => !this.getContentNodeIsValid(id)); }, }, - created() { - if (this.isAIFeatureEnabled && this.nodeIds.length === 1) { - loadCaption(this.nodeIds, this.loadCaptionFiles, this.loadCaptionCues); - } - }, beforeRouteEnter(to, from, next) { if ( to.name === RouteNames.CONTENTNODE_DETAILS || @@ -347,6 +341,11 @@ vm.loadFiles({ contentnode__in: childrenNodesIds }), vm.loadAssessmentItems({ contentnode__in: childrenNodesIds }), ]; + if(childrenNodesIds.length === 1) { + promises.push( + vm.loadCaptions({ contentnode_id: childrenNodesIds }) + ); + } } else { // no need to load assessment items or files as topics have none promises = [vm.loadContentNode(parentTopicId)]; @@ -400,7 +399,7 @@ ]), ...mapActions('file', ['loadFiles', 'updateFile']), ...mapActions('assessmentItem', ['loadAssessmentItems', 'updateAssessmentItems']), - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), ...mapMutations('contentNode', { enableValidation: 'ENABLE_VALIDATION_ON_NODES' }), closeModal() { this.promptUploading = false; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue index 5f221e9b72..f32b5d9709 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue @@ -103,7 +103,7 @@ - + @@ -120,7 +120,7 @@ import { TabNames } from '../../constants'; import AssessmentTab from '../../components/AssessmentTab/AssessmentTab'; - import CaptionsEditor from '../../components/CaptionsEditor/CaptionsEditor' + import CaptionsTab from '../../components/CaptionsTab/CaptionsTab' import RelatedResourcesTab from '../../components/RelatedResourcesTab/RelatedResourcesTab'; import DetailsTabView from './DetailsTabView'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -131,7 +131,7 @@ name: 'EditView', components: { AssessmentTab, - CaptionsEditor, + CaptionsTab, DetailsTabView, RelatedResourcesTab, Tabs, diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 3ab58f3a6b..97dd9d4713 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,4 +1,3 @@ -import store from './store'; import translator from './translator'; import { RouteNames } from './constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -390,28 +389,3 @@ export function getCompletionCriteriaLabels(node = {}, files = []) { return labels; } - -/** - * Loads caption files and cues for a content node. - * - * Caption files and cues are only loaded when: - * - The AI feature flag is enabled - * - A single node is being edited - * - The node kind is 'video' or 'audio' - * - * @param {Array} nodeIds - Array containing node ID(s) being edited - * @param {Function} loadCaptionFiles - Vuex Action to load caption files - * @param {Function} loadCaptionCues - Vuex Action to load caption cues - */ -export function loadCaption(nodeIds, loadCaptionFiles, loadCaptionCues) { - - const CONTENTNODEID = nodeIds[0]; - const KIND = store.getters["contentNode/getContentNode"](CONTENTNODEID).kind; - - if(KIND === 'video' || KIND === 'audio') { - loadCaptionFiles({ contentnode_id: CONTENTNODEID }).then(captionFile => { - let captionFileID = captionFile[0].id; // FK to retrieve the cues of a caption file - loadCaptionCues({ caption_file_id: captionFileID }) - }) - } -} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index 5db52560cd..2e62788f3f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -1,8 +1,8 @@ import { CaptionFile, CaptionCues } from 'shared/data/resources'; -export async function loadCaptionFiles({ commit }, params) { +export async function loadCaptionFiles(commit, params) { const captionFiles = await CaptionFile.where(params); - commit('ADD_CAPTIONFILES', captionFiles); + commit('ADD_CAPTIONFILES', { captionFiles, nodeId: params.contentnode_id}); return captionFiles; } @@ -11,3 +11,21 @@ export async function loadCaptionCues({ commit }, { caption_file_id }) { commit('ADD_CAPTIONCUES', cues); return cues; } + +export async function loadCaptions({ commit, rootGetters }, params) { + const AI_FEATURE_FLAG = rootGetters['currentChannel/isAIFeatureEnabled'] + if(!AI_FEATURE_FLAG) return; + + const FILE_TYPE = rootGetters['contentNode/getContentNode'](params.contentnode_id).kind; + if(FILE_TYPE === 'video' || FILE_TYPE === 'audio') { + const captionFiles = await loadCaptionFiles(commit, params); + // If there is no Caption File for this contentnode + // Don't request for the cues + if(captionFiles.length === 0) return; + // TODO: call loadCaptionCues -> to be done after I finish saving captionFiles in indexedDB When CTA is called. So I have captions saved in the backend. + } +} + +export async function addCaptionFile({ commit }, { captionFile, nodeId }) { + commit('ADD_CAPTIONFILE', { captionFile, nodeId }); +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js index d74808f3d8..f1050ea879 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js @@ -1,3 +1,3 @@ -// export function getCaptionFiles(state) { -// return Object.values(state.captionFilesMap); -// } +export function getContentNodeId(state) { + return state.currentContentNode.contentnode_id; +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js index 399bc64436..e30006eda2 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js @@ -7,9 +7,16 @@ export default { namespaced: true, state: () => ({ /* List of caption files for a contentnode - * to be defined + * [ + * contentnode_id: { + * pk: { + * file_id: file_id + * language: language + * } + * }, + * ] */ - captionFilesMap: {}, + captionFilesMap: [], /* Caption Cues json to render in the frontend caption-editor * to be defined */ @@ -20,7 +27,7 @@ export default { actions, listeners: { [TABLE_NAMES.CAPTION_FILE]: { - [CHANGE_TYPES.CREATED]: 'ADD_CAPTIONFILES', + [CHANGE_TYPES.CREATED]: 'ADD_CAPTIONFILE', [CHANGE_TYPES.UPDATED]: 'UPDATE_CAPTIONFILE_FROM_INDEXEDDB', [CHANGE_TYPES.DELETED]: 'DELETE_CAPTIONFILE', }, diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js index 78970da8a0..dd0b071d24 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js @@ -1,16 +1,34 @@ import Vue from "vue"; /* Mutations for Caption File */ -export function ADD_CAPTIONFILE(state, captionFile) { - // TODO: add some checks to File +export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { + if(!state.captionFilesMap[nodeId]) { + Vue.set(state.captionFilesMap, nodeId, {}); + } + + // Check if the pk exists in the contentNode's object + if (!state.captionFilesMap[nodeId][captionFile.id]) { + // If it doesn't exist, create an empty object for that pk + Vue.set(state.captionFilesMap[nodeId], captionFile.id, {}); + } + + // Check if the file_id and language combination already exists + const key = `${captionFile.file_id}_${captionFile.language}`; + // if(state.captionFilesMap[nodeId][captionFile.id]) { + + // } - Vue.set(state.captionFilesMap, captionFile.id, captionFile); + // Finally, set the file_id and language for that pk + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'id', captionFile.id); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'file_id', captionFile.file_id); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'language', captionFile.language); + console.log(state.captionFilesMap); } -export function ADD_CAPTIONFILES(state, captionFiles = []) { - if (Array.isArray(captionFiles)) { // Workaround to fix TypeError: captionFiles.forEach +export function ADD_CAPTIONFILES(state, captionFiles, nodeId) { + if (Array.isArray(captionFiles)) { captionFiles.forEach(captionFile => { - ADD_CAPTIONFILE(state, captionFile); + ADD_CAPTIONFILE(state, captionFile, nodeId); }); } } From 099963a4badeab8710c699fdcab32b806346c580 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Thu, 27 Jul 2023 22:46:37 +0530 Subject: [PATCH 050/257] Stage changes before rebase --- .../components/CaptionsTab/CaptionsTab.vue | 31 ++---- .../components/CaptionsTab/languages.js | 101 ++++++++++++++++++ .../channelEdit/components/edit/EditList.vue | 11 -- .../channelEdit/components/edit/EditModal.vue | 6 +- .../channelEdit/vuex/caption/actions.js | 54 +++++++--- .../channelEdit/vuex/caption/mutations.js | 17 ++- .../contentcuration/viewsets/caption.py | 6 +- 7 files changed, 164 insertions(+), 62 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue index ef7322e50d..eaa2346916 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue @@ -20,7 +20,7 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index 5c9c2d6a21..53d5223333 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -37,7 +37,9 @@ export async function loadCaptions({ commit, rootGetters }, params) { // If there is no Caption File for this contentnode // Don't request for the cues if(captionFiles.length === 0) return; - // TODO: call loadCaptionCues -> to be done after I finish saving captionFiles in indexedDB When CTA is called. So I have captions saved in the backend. + // TODO: call loadCaptionCues -> to be done after + // I finish saving captionFiles in indexedDB When + // CTA is called. So I have captions saved in the backend. } @@ -46,7 +48,7 @@ export async function addCaptionFile({ commit }, { file_id, language, nodeId }) file_id: file_id, language: language } - return CaptionFile.put(captionFile).then(id => { + return CaptionFile.add(captionFile).then(id => { captionFile.id = id; console.log(captionFile, nodeId); commit('ADD_CAPTIONFILE', { diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 88536368b2..39cf44f17f 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1023,6 +1023,7 @@ export const CaptionFile = new Resource({ idField: 'id', indexFields: ['file_id', 'language'], syncable: true, + getChannelId: getChannelFromChannelScope, }); export const CaptionCues = new Resource({ @@ -1031,6 +1032,7 @@ export const CaptionCues = new Resource({ idField: 'id', indexFields: ['text', 'starttime', 'endtime'], syncable: true, + getChannelId: getChannelFromChannelScope, collectionUrl(caption_file_id) { return this.getUrlFunction('list')(caption_file_id) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js b/contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js similarity index 73% rename from contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js rename to contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js index 703d856715..c7bf2a4f1e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js +++ b/contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js @@ -1,4 +1,13 @@ -export const CAPTIONS_LANGUAGES = { +/** + * This file generates the list of supported caption languages by + * filtering the full list of languages against the whisperLanguages object. + * To switch to a new model for supported languages, you can update the + * whisperLanguages object with new language codes and names. +*/ + +import { LanguagesList } from 'shared/leUtils/Languages'; + +const whisperLanguages = { en: "english", zh: "chinese", de: "german", @@ -98,4 +107,14 @@ export const CAPTIONS_LANGUAGES = { ba: "bashkir", jw: "javanese", su: "sundanese", -} \ No newline at end of file +} + +export const supportedCaptionLanguages = LanguagesList.filter( + (language) => whisperLanguages.hasOwnProperty(language.lang_code) +); + +export const notSupportedCaptionLanguages = LanguagesList.filter( + (language) => !whisperLanguages.hasOwnProperty(language.lang_code) +); + +export default supportedCaptionLanguages; diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py deleted file mode 100644 index b8176d6a02..0000000000 --- a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-26 17:33 - -import contentcuration.models -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='CaptionFile', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), - ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), - ], - options={ - 'unique_together': {('file_id', 'language')}, - }, - ), - migrations.CreateModel( - name='CaptionCue', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('text', models.TextField()), - ('starttime', models.FloatField()), - ('endtime', models.FloatField()), - ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0145_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0145_captioncue_captionfile.py new file mode 100644 index 0000000000..29d5bffb97 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0145_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-08-01 06:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0144_soft_delete_user'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_file', to='contentcuration.language')), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 25896bb34b..c4f55d1eb3 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2070,13 +2070,19 @@ class CaptionFile(models.Model): """ id = UUIDField(primary_key=True, default=uuid.uuid4) file_id = UUIDField(default=uuid.uuid4, max_length=36) - language = models.CharField(choices=CAPTIONS_LANGUAGES, max_length=3) + language = models.ForeignKey(Language, related_name="caption_file", on_delete=models.CASCADE) class Meta: unique_together = ['file_id', 'language'] def __str__(self): return "file_id: {file_id}, language: {language}".format(file_id=self.file_id, language=self.language) + + def save(self, *args, **kwargs): + # Check if the language is supported by speech-to-text AI model. + if self.language and self.language.lang_code not in CAPTIONS_LANGUAGES: + raise ValueError(f"The language is currently not supported by speech-to-text model.") + super(CaptionFile, self).save(*args, **kwargs) class CaptionCue(models.Model): diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 6931d5bdbb..e4579b83a9 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -3,7 +3,7 @@ import json import uuid -from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.models import CaptionCue, CaptionFile, Language from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import ( @@ -21,7 +21,7 @@ class SyncTestCase(SyncTestMixin, StudioAPITestCase): def caption_file_metadata(self): return { "file_id": uuid.uuid4().hex, - "language": "en", + "language": Language.objects.get(pk="en").pk, } @property @@ -30,27 +30,20 @@ def same_file_different_language_metadata(self): return [ { "file_id": id, - "language": "en", + "language": Language.objects.get(pk="en"), }, { "file_id": id, - "language": "ru", + "language": Language.objects.get(pk="ru"), }, ] - @property - def caption_file_db_metadata(self): - return { - "file_id": uuid.uuid4().hex, - "language": "en", - } - @property def caption_cue_metadata(self): return { "file": { "file_id": uuid.uuid4().hex, - "language": "en", + "language": Language.objects.get(pk="en").pk, }, "cue": { "text": "This is the beginning!", @@ -65,7 +58,6 @@ def setUp(self): self.user = testdata.user() self.channel.editors.add(self.user) - # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata @@ -85,18 +77,20 @@ def test_create_caption(self): try: caption_file_db = CaptionFile.objects.get( file_id=caption_file["file_id"], - language=caption_file["language"], + language_id=caption_file["language"], ) except CaptionFile.DoesNotExist: self.fail("caption file was not created") # Check the values of the object in the PostgreSQL self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) - self.assertEqual(caption_file_db.language, caption_file["language"]) + self.assertEqual(caption_file_db.language_id, caption_file["language"]) def test_delete_caption_file(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata + # Explicitly set language to model object to follow Django ORM conventions + caption_file['language'] = Language.objects.get(pk='en') caption_file_1 = CaptionFile(**caption_file) pk = caption_file_1.pk @@ -108,7 +102,7 @@ def test_delete_caption_file(self): with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file["file_id"], language=caption_file["language"] + file_id=caption_file["file_id"], language_id=caption_file["language"] ) def test_delete_file_with_same_file_id_different_language(self): @@ -132,11 +126,13 @@ def test_delete_file_with_same_file_id_different_language(self): with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file_2.file_id, language=caption_file_2.language + file_id=caption_file_2.file_id, language_id=caption_file_2.language ) def test_caption_file_serialization(self): metadata = self.caption_file_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['language'] = Language.objects.get(pk="en") caption_file = CaptionFile.objects.create(**metadata) serializer = CaptionFileSerializer(instance=caption_file) try: @@ -146,6 +142,8 @@ def test_caption_file_serialization(self): def test_caption_cue_serialization(self): metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update( @@ -166,6 +164,8 @@ def test_caption_cue_serialization(self): def test_create_caption_cue(self): self.client.force_authenticate(user=self.user) metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file_1 = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue["caption_file_id"] = caption_file_1.pk @@ -194,6 +194,8 @@ def test_create_caption_cue(self): def test_delete_caption_cue(self): self.client.force_authenticate(user=self.user) metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file_1 = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update({"caption_file": caption_file_1}) @@ -228,6 +230,8 @@ def test_delete_caption_cue(self): def test_update_caption_cue(self): self.client.force_authenticate(user=self.user) metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file_1 = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] @@ -280,6 +284,8 @@ def test_update_caption_cue(self): def test_invalid_caption_cue_data_serialization(self): metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update( diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 09550a7655..419605f0a5 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -2,7 +2,6 @@ AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES, - VIDEO_SUBTITLE, ) from rest_framework import serializers from rest_framework.permissions import IsAuthenticated @@ -26,6 +25,13 @@ def validate(self, attrs): return attrs def to_internal_value(self, data): + """ + Copies the caption_file_id from the request data + to the internal representation before validation. + + Without this, the caption_file_id would be lost + if validation fails, leading to errors. + """ caption_file_id = data.get("caption_file_id") value = super().to_internal_value(data) @@ -43,6 +49,12 @@ class Meta: @classmethod def id_attr(cls): + """ + Returns the primary key name for the model class. + + Checks Meta.update_lookup_field to allow customizable + primary key names. Falls back to using the default "id". + """ ModelClass = cls.Meta.model info = model_meta.get_field_info(ModelClass) return getattr(cls.Meta, "update_lookup_field", info.pk.name) @@ -63,13 +75,14 @@ class CaptionViewSet(ValuesViewset): def get_queryset(self): queryset = super().get_queryset() - contentnode_ids = self.request.GET.get("contentnode_id").split(',') + contentnode_ids = self.request.GET.get("contentnode_id") file_id = self.request.GET.get("file_id") language = self.request.GET.get("language") if contentnode_ids: + contentnode_ids = contentnode_ids.split(',') file_ids = File.objects.filter( - preset_id__in=[AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES, VIDEO_SUBTITLE], + preset_id__in=[AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES], contentnode_id__in=contentnode_ids, ).values_list("pk", flat=True) queryset = queryset.filter(file_id__in=file_ids) From 4c661eedb31970842bef4779e0466c0df3136a97 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 23 May 2023 16:51:29 +0530 Subject: [PATCH 052/257] created captionviewset --- contentcuration/contentcuration/urls.py | 1 + .../contentcuration/viewsets/captions.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 contentcuration/contentcuration/viewsets/captions.py diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 692c8cc938..52b74bbf60 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,6 +32,7 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet +from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import AdminChannelViewSet diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py new file mode 100644 index 0000000000..148a187698 --- /dev/null +++ b/contentcuration/contentcuration/viewsets/captions.py @@ -0,0 +1,27 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +# from contentcuration.models import GeneratedCaptions + + +class GeneratedCaptionsSerializer(serializers.ModelSerializer): + class Meta: + # model = GeneratedCaptions + fields = ['id', 'generated_captions', 'language'] + +class CaptionViewSet(ModelViewSet): + # queryset = GeneratedCaptions.objects.all() + serializer_class = GeneratedCaptionsSerializer + + def create(self, request): + # handles the creation operation and return serialized data + pass + + def update(self, request): + # handles the updating of an existing `GeneratedCaption` instance. + pass + + def destroy(self, request): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file From e34a98c96296e55eb74aa94c8237dc66eac12e73 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Mon, 19 Jun 2023 12:40:39 +0530 Subject: [PATCH 053/257] Adds captions modal with visibility controlled by featureflag --- .../CaptionsEditor/CaptionsEditor.vue | 19 +++++++++++++++ .../frontend/shared/data/resources.js | 4 ++++ .../migrations/0143_caption.py | 23 +++++++++++++++++++ contentcuration/contentcuration/urls.py | 1 - 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue create mode 100644 contentcuration/contentcuration/migrations/0143_caption.py diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue new file mode 100644 index 0000000000..33f25de838 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 39cf44f17f..748d6ae298 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1076,6 +1076,10 @@ export const CaptionCues = new Resource({ } }); +export const Caption = new Resource({ + // TODO +}) + export const Channel = new Resource({ tableName: TABLE_NAMES.CHANNEL, urlName: 'channel', diff --git a/contentcuration/contentcuration/migrations/0143_caption.py b/contentcuration/contentcuration/migrations/0143_caption.py new file mode 100644 index 0000000000..3d6e0e769c --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_caption.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.14 on 2023-06-15 06:13 + +import contentcuration.models +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='Caption', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('caption', models.JSONField()), + ('language', models.CharField(max_length=10)), + ], + ), + ] diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 52b74bbf60..692c8cc938 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,7 +32,6 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet -from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import AdminChannelViewSet From b1c0bbeecebdf1797eb4272484cc47db0a466aa7 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:29:13 +0530 Subject: [PATCH 054/257] Adds Sync API tests for CaptionFile ViewSet --- .../constants/transcription_languages.py | 236 ++++++++---------- .../migrations/0143_caption.py | 23 -- .../migrations/0143_captioncue_captionfile.py | 37 +++ contentcuration/contentcuration/models.py | 1 + 4 files changed, 139 insertions(+), 158 deletions(-) delete mode 100644 contentcuration/contentcuration/migrations/0143_caption.py create mode 100644 contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py diff --git a/contentcuration/contentcuration/constants/transcription_languages.py b/contentcuration/contentcuration/constants/transcription_languages.py index 7eeefca57c..c442e04813 100644 --- a/contentcuration/contentcuration/constants/transcription_languages.py +++ b/contentcuration/contentcuration/constants/transcription_languages.py @@ -1,136 +1,102 @@ -# This file provides a list of transcription languages supported by OpenAI/Whisper. -# The list of languages is obtained from the Whisper project on GitHub, specifically from the tokenizer module. -# You can find the complete list of available languages in the tokenizer module: -# https://github.com/openai/whisper/blob/main/whisper/tokenizer.py - -# The supported languages are stored in the 'LANGUAGES' dictionary in the format of language code and language name. -# For example, the first element in the 'LANGUAGES' dictionary is ('en', 'english'). - -# To add support for a new model, we also need to update the `supportedLanguageList` array in the frontend TranscriptionLanguages.js file. -# https://github.com/learningequality/studio/blob/unstable/contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js - -import json -import le_utils.resources as resources - -WHISPER_LANGUAGES = { - "en": "english", - "zh": "chinese", - "de": "german", - "es": "spanish", - "ru": "russian", - "ko": "korean", - "fr": "french", - "ja": "japanese", - "pt": "portuguese", - "tr": "turkish", - "pl": "polish", - "ca": "catalan", - "nl": "dutch", - "ar": "arabic", - "sv": "swedish", - "it": "italian", - "id": "indonesian", - "hi": "hindi", - "fi": "finnish", - "vi": "vietnamese", - "he": "hebrew", - "uk": "ukrainian", - "el": "greek", - "ms": "malay", - "cs": "czech", - "ro": "romanian", - "da": "danish", - "hu": "hungarian", - "ta": "tamil", - "no": "norwegian", - "th": "thai", - "ur": "urdu", - "hr": "croatian", - "bg": "bulgarian", - "lt": "lithuanian", - "la": "latin", - "mi": "maori", - "ml": "malayalam", - "cy": "welsh", - "sk": "slovak", - "te": "telugu", - "fa": "persian", - "lv": "latvian", - "bn": "bengali", - "sr": "serbian", - "az": "azerbaijani", - "sl": "slovenian", - "kn": "kannada", - "et": "estonian", - "mk": "macedonian", - "br": "breton", - "eu": "basque", - "is": "icelandic", - "hy": "armenian", - "ne": "nepali", - "mn": "mongolian", - "bs": "bosnian", - "kk": "kazakh", - "sq": "albanian", - "sw": "swahili", - "gl": "galician", - "mr": "marathi", - "pa": "punjabi", - "si": "sinhala", - "km": "khmer", - "sn": "shona", - "yo": "yoruba", - "so": "somali", - "af": "afrikaans", - "oc": "occitan", - "ka": "georgian", - "be": "belarusian", - "tg": "tajik", - "sd": "sindhi", - "gu": "gujarati", - "am": "amharic", - "yi": "yiddish", - "lo": "lao", - "uz": "uzbek", - "fo": "faroese", - "ht": "haitian creole", - "ps": "pashto", - "tk": "turkmen", - "nn": "nynorsk", - "mt": "maltese", - "sa": "sanskrit", - "lb": "luxembourgish", - "my": "myanmar", - "bo": "tibetan", - "tl": "tagalog", - "mg": "malagasy", - "as": "assamese", - "tt": "tatar", - "haw": "hawaiian", - "ln": "lingala", - "ha": "hausa", - "ba": "bashkir", - "jw": "javanese", - "su": "sundanese", -} - -def _load_kolibri_languages(): - """Load Kolibri languages from JSON file and return the language codes as a list.""" - filepath = resources.__path__[0] - kolibri_languages = [] - with open(f'{filepath}/languagelookup.json') as f: - kolibri_languages = list(json.load(f).keys()) - return kolibri_languages - -def _load_model_languages(languages): - """Load languages supported by the speech-to-text model.""" - return list(languages.keys()) - -def create_captions_languages(): - """Create the intersection of transcription model and Kolibri languages.""" - kolibri_set = set(_load_kolibri_languages()) - model_set = set(_load_model_languages(languages=WHISPER_LANGUAGES)) - return list(kolibri_set.intersection(model_set)) - -CAPTIONS_LANGUAGES = create_captions_languages() +CAPTIONS_LANGUAGES = [ + ("en", "english"), + ("zh", "chinese"), + ("de", "german"), + ("es", "spanish"), + ("ru", "russian"), + ("ko", "korean"), + ("fr", "french"), + ("ja", "japanese"), + ("pt", "portuguese"), + ("tr", "turkish"), + ("pl", "polish"), + ("ca", "catalan"), + ("nl", "dutch"), + ("ar", "arabic"), + ("sv", "swedish"), + ("it", "italian"), + ("id", "indonesian"), + ("hi", "hindi"), + ("fi", "finnish"), + ("vi", "vietnamese"), + ("he", "hebrew"), + ("uk", "ukrainian"), + ("el", "greek"), + ("ms", "malay"), + ("cs", "czech"), + ("ro", "romanian"), + ("da", "danish"), + ("hu", "hungarian"), + ("ta", "tamil"), + ("no", "norwegian"), + ("th", "thai"), + ("ur", "urdu"), + ("hr", "croatian"), + ("bg", "bulgarian"), + ("lt", "lithuanian"), + ("la", "latin"), + ("mi", "maori"), + ("ml", "malayalam"), + ("cy", "welsh"), + ("sk", "slovak"), + ("te", "telugu"), + ("fa", "persian"), + ("lv", "latvian"), + ("bn", "bengali"), + ("sr", "serbian"), + ("az", "azerbaijani"), + ("sl", "slovenian"), + ("kn", "kannada"), + ("et", "estonian"), + ("mk", "macedonian"), + ("br", "breton"), + ("eu", "basque"), + ("is", "icelandic"), + ("hy", "armenian"), + ("ne", "nepali"), + ("mn", "mongolian"), + ("bs", "bosnian"), + ("kk", "kazakh"), + ("sq", "albanian"), + ("sw", "swahili"), + ("gl", "galician"), + ("mr", "marathi"), + ("pa", "punjabi"), + ("si", "sinhala"), + ("km", "khmer"), + ("sn", "shona"), + ("yo", "yoruba"), + ("so", "somali"), + ("af", "afrikaans"), + ("oc", "occitan"), + ("ka", "georgian"), + ("be", "belarusian"), + ("tg", "tajik"), + ("sd", "sindhi"), + ("gu", "gujarati"), + ("am", "amharic"), + ("yi", "yiddish"), + ("lo", "lao"), + ("uz", "uzbek"), + ("fo", "faroese"), + ("ht", "haitian creole"), + ("ps", "pashto"), + ("tk", "turkmen"), + ("nn", "nynorsk"), + ("mt", "maltese"), + ("sa", "sanskrit"), + ("lb", "luxembourgish"), + ("my", "myanmar"), + ("bo", "tibetan"), + ("tl", "tagalog"), + ("mg", "malagasy"), + ("as", "assamese"), + ("tt", "tatar"), + ("haw", "hawaiian"), + ("ln", "lingala"), + ("ha", "hausa"), + ("ba", "bashkir"), + ("jw", "javanese"), + ("su", "sundanese"), +] \ No newline at end of file diff --git a/contentcuration/contentcuration/migrations/0143_caption.py b/contentcuration/contentcuration/migrations/0143_caption.py deleted file mode 100644 index 3d6e0e769c..0000000000 --- a/contentcuration/contentcuration/migrations/0143_caption.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-15 06:13 - -import contentcuration.models -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='Caption', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('caption', models.JSONField()), - ('language', models.CharField(max_length=10)), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py new file mode 100644 index 0000000000..b8176d6a02 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-06-26 17:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index c4f55d1eb3..9f43d55db8 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,6 +2,7 @@ import json import logging import os +from typing import Any import urllib.parse import uuid from datetime import datetime From 6c8936653f196d1c848de1aeed740014f3ec5738 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:33:46 +0530 Subject: [PATCH 055/257] Removes unnecessary imports --- contentcuration/contentcuration/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 9f43d55db8..c4f55d1eb3 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,7 +2,6 @@ import json import logging import os -from typing import Any import urllib.parse import uuid from datetime import datetime From ca79d44234e5f695224e6fcca00cff5211e7c267 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:11:16 +0530 Subject: [PATCH 056/257] Creating CaptionCue with generate_create_event fails --- .../channelEdit/components/CaptionsEditor/CaptionsEditor.vue | 2 +- contentcuration/contentcuration/tests/viewsets/test_caption.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 33f25de838..531dc7d90f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -16,4 +16,4 @@ \ No newline at end of file + diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index e4579b83a9..f88ff780ec 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -57,7 +57,7 @@ def setUp(self): self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - + # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata From 00b58121f78a050b62a4be858bd712f3406cfbdb Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 7 Jul 2023 13:12:04 +0530 Subject: [PATCH 057/257] Adds caption editor components, updated IndexedDB Resource --- .../CaptionsEditor/CaptionsEditor.vue | 15 +++-- .../channelEdit/vuex/caption/actions.js | 2 +- .../channelEdit/vuex/caption/getters.js | 2 +- .../frontend/shared/data/resources.js | 17 +++++- .../tests/viewsets/test_caption.py | 20 +++++++ .../contentcuration/viewsets/caption.py | 55 +++++++++++++++++++ 6 files changed, 102 insertions(+), 9 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 531dc7d90f..4efd617226 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -1,17 +1,24 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 4cbf88eadd..91401ed102 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -25,7 +25,6 @@ import { mapGetters, mapActions } from 'vuex'; import EditListItem from './EditListItem'; import Checkbox from 'shared/views/form/Checkbox'; - import { loadCaption } from '../../utils'; export default { name: 'EditList', @@ -44,16 +43,15 @@ }, }, watch: { - // watch the selected contentnode + // watch the selected contentnode to load captions value(newValue) { - if (this.isAIFeatureEnabled && newValue.length === 1) { - const nodeId = newValue[0]; - loadCaption([nodeId], this.loadCaptionFiles, this.loadCaptionCues); + if (newValue.length === 1) { + const nodeId = newValue[0]; // we dispatch action only when 1 node is selected + this.loadCaptions({ contentnode_id: nodeId }) } } }, computed: { - ...mapGetters('currentChannel', ['isAIFeatureEnabled']), selected: { get() { return this.value; @@ -77,7 +75,7 @@ }, }, methods: { - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), handleRemoved(nodeId) { const nodeIds = this.$route.params.detailNodeIds.split(',').filter(id => id !== nodeId); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 3b9ada9b41..26cd0c0919 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -197,7 +197,6 @@ import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; import { DELAYED_VALIDATION } from 'shared/constants'; - import { loadCaption } from '../../utils'; const CHECK_STORAGE_INTERVAL = 10000; @@ -251,7 +250,7 @@ computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), - ...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'isAIFeatureEnabled']), + ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), ...mapGetters('file', ['contentNodesAreUploading', 'getContentNodeFiles']), ...mapState({ online: state => state.connection.online, @@ -307,11 +306,6 @@ return this.nodeIds.filter(id => !this.getContentNodeIsValid(id)); }, }, - created() { - if (this.isAIFeatureEnabled && this.nodeIds.length === 1) { - loadCaption(this.nodeIds, this.loadCaptionFiles, this.loadCaptionCues); - } - }, beforeRouteEnter(to, from, next) { if ( to.name === RouteNames.CONTENTNODE_DETAILS || @@ -348,6 +342,11 @@ vm.loadAssessmentItems({ contentnode__in: childrenNodesIds }), vm.loadCaptions({ contentnode_id: childrenNodesIds }) ]; + if(childrenNodesIds.length === 1) { + promises.push( + vm.loadCaptions({ contentnode_id: childrenNodesIds }) + ); + } } else { // no need to load assessment items or files as topics have none promises = [vm.loadContentNode(parentTopicId)]; diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 3ab58f3a6b..97dd9d4713 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,4 +1,3 @@ -import store from './store'; import translator from './translator'; import { RouteNames } from './constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -390,28 +389,3 @@ export function getCompletionCriteriaLabels(node = {}, files = []) { return labels; } - -/** - * Loads caption files and cues for a content node. - * - * Caption files and cues are only loaded when: - * - The AI feature flag is enabled - * - A single node is being edited - * - The node kind is 'video' or 'audio' - * - * @param {Array} nodeIds - Array containing node ID(s) being edited - * @param {Function} loadCaptionFiles - Vuex Action to load caption files - * @param {Function} loadCaptionCues - Vuex Action to load caption cues - */ -export function loadCaption(nodeIds, loadCaptionFiles, loadCaptionCues) { - - const CONTENTNODEID = nodeIds[0]; - const KIND = store.getters["contentNode/getContentNode"](CONTENTNODEID).kind; - - if(KIND === 'video' || KIND === 'audio') { - loadCaptionFiles({ contentnode_id: CONTENTNODEID }).then(captionFile => { - let captionFileID = captionFile[0].id; // FK to retrieve the cues of a caption file - loadCaptionCues({ caption_file_id: captionFileID }) - }) - } -} From 28a27f7b84e195b5ad5a2af1747368b1fa32199c Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 1 Aug 2023 23:41:06 +0530 Subject: [PATCH 064/257] maybe this will break the --- .../channelEdit/vuex/caption/actions.js | 45 +------------------ .../tests/viewsets/test_caption.py | 19 -------- 2 files changed, 1 insertion(+), 63 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index b6195372d8..a99608b4a8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -1,46 +1,3 @@ -import { CaptionFile, CaptionCues } from 'shared/data/resources'; - -export async function loadCaptionFiles(commit, params) { - const captionFiles = await CaptionFile.where(params); - commit('ADD_CAPTIONFILES', { captionFiles, nodeIds: params.contentnode_id }); - return captionFiles; -} - -export async function loadCaptionCues({ commit }, { caption_file_id }) { - const cues = await CaptionCues.where({ caption_file_id }) - commit('ADD_CAPTIONCUES', cues); - return cues; -} - -export async function loadCaptions({ commit, rootGetters }, params) { - const isAIFeatureEnabled = rootGetters['currentChannel/isAIFeatureEnabled']; - if(!isAIFeatureEnabled) return; - // If a new file is uploaded, the contentnode_id will be string - if(typeof params.contentnode_id === 'string') { - params.contentnode_id = [params.contentnode_id] - } - const nodeIdsToLoad = []; - for (const nodeId of params.contentnode_id) { - const node = rootGetters['contentNode/getContentNode'](nodeId); - if (node && (node.kind === 'video' || node.kind === 'audio')) { - nodeIdsToLoad.push(nodeId); // already in vuex - } else if(!node) { - nodeIdsToLoad.push(nodeId); // Assume that its audio/video - } - } - - const captionFiles = await loadCaptionFiles(commit, { - contentnode_id: nodeIdsToLoad - }); - - // If there is no Caption File for this contentnode - // Don't request for the cues - if(captionFiles.length === 0) return; - // TODO: call loadCaptionCues -> to be done after - // I finish saving captionFiles in indexedDB When - // CTA is called. So I have captions saved in the backend. -} - export async function addCaptionFile({ commit }, { file_id, language, nodeId }) { const captionFile = { @@ -55,4 +12,4 @@ export async function addCaptionFile({ commit }, { file_id, language, nodeId }) nodeId }); }) -} +} \ No newline at end of file diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 40c6c08fa0..7d2a699230 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -3,13 +3,7 @@ import json import uuid -<<<<<<< HEAD from contentcuration.models import CaptionCue, CaptionFile, Language -======= -from django.core.serializers import serialize - -from contentcuration.models import CaptionCue, CaptionFile ->>>>>>> de52608ba (Adds caption editor components, updated IndexedDB Resource) from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import ( @@ -20,11 +14,6 @@ ) from contentcuration.viewsets.caption import CaptionCueSerializer, CaptionFileSerializer from contentcuration.viewsets.sync.constants import CAPTION_CUES, CAPTION_FILE -<<<<<<< HEAD -======= - -# class CRUDTestCase(StudioAPITestCase): ->>>>>>> de52608ba (Adds caption editor components, updated IndexedDB Resource) class SyncTestCase(SyncTestMixin, StudioAPITestCase): @@ -53,13 +42,8 @@ def same_file_different_language_metadata(self): def caption_cue_metadata(self): return { "file": { -<<<<<<< HEAD "file_id": uuid.uuid4().hex, "language": Language.objects.get(pk="en").pk, -======= - "file_id": uuid.uuid4(), - "language": "en", ->>>>>>> de52608ba (Adds caption editor components, updated IndexedDB Resource) }, "cue": { "text": "This is the beginning!", @@ -301,11 +285,8 @@ def test_update_caption_cue(self): def test_invalid_caption_cue_data_serialization(self): metadata = self.caption_cue_metadata -<<<<<<< HEAD # Explicitly set language to model object to follow Django ORM conventions metadata['file']['language'] = Language.objects.get(pk="en") -======= ->>>>>>> de52608ba (Adds caption editor components, updated IndexedDB Resource) caption_file = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update( From 76b80f01548d3eedefe86aa452cc5e48c08cfd21 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Wed, 2 Aug 2023 00:27:10 +0530 Subject: [PATCH 065/257] fixs merge conflict --- .../constants/transcription_languages.py | 234 ++++++++++-------- .../channelEdit/components/edit/EditList.vue | 11 - .../channelEdit/components/edit/EditModal.vue | 5 - .../frontend/shared/data/resources.js | 57 ----- 4 files changed, 133 insertions(+), 174 deletions(-) diff --git a/contentcuration/contentcuration/constants/transcription_languages.py b/contentcuration/contentcuration/constants/transcription_languages.py index c442e04813..e68fcaceb1 100644 --- a/contentcuration/contentcuration/constants/transcription_languages.py +++ b/contentcuration/contentcuration/constants/transcription_languages.py @@ -1,102 +1,134 @@ +# The list of languages is obtained from the Whisper project on GitHub, specifically from the tokenizer module. +# You can find the complete list of available languages in the tokenizer module: +# https://github.com/openai/whisper/blob/main/whisper/tokenizer.py -CAPTIONS_LANGUAGES = [ - ("en", "english"), - ("zh", "chinese"), - ("de", "german"), - ("es", "spanish"), - ("ru", "russian"), - ("ko", "korean"), - ("fr", "french"), - ("ja", "japanese"), - ("pt", "portuguese"), - ("tr", "turkish"), - ("pl", "polish"), - ("ca", "catalan"), - ("nl", "dutch"), - ("ar", "arabic"), - ("sv", "swedish"), - ("it", "italian"), - ("id", "indonesian"), - ("hi", "hindi"), - ("fi", "finnish"), - ("vi", "vietnamese"), - ("he", "hebrew"), - ("uk", "ukrainian"), - ("el", "greek"), - ("ms", "malay"), - ("cs", "czech"), - ("ro", "romanian"), - ("da", "danish"), - ("hu", "hungarian"), - ("ta", "tamil"), - ("no", "norwegian"), - ("th", "thai"), - ("ur", "urdu"), - ("hr", "croatian"), - ("bg", "bulgarian"), - ("lt", "lithuanian"), - ("la", "latin"), - ("mi", "maori"), - ("ml", "malayalam"), - ("cy", "welsh"), - ("sk", "slovak"), - ("te", "telugu"), - ("fa", "persian"), - ("lv", "latvian"), - ("bn", "bengali"), - ("sr", "serbian"), - ("az", "azerbaijani"), - ("sl", "slovenian"), - ("kn", "kannada"), - ("et", "estonian"), - ("mk", "macedonian"), - ("br", "breton"), - ("eu", "basque"), - ("is", "icelandic"), - ("hy", "armenian"), - ("ne", "nepali"), - ("mn", "mongolian"), - ("bs", "bosnian"), - ("kk", "kazakh"), - ("sq", "albanian"), - ("sw", "swahili"), - ("gl", "galician"), - ("mr", "marathi"), - ("pa", "punjabi"), - ("si", "sinhala"), - ("km", "khmer"), - ("sn", "shona"), - ("yo", "yoruba"), - ("so", "somali"), - ("af", "afrikaans"), - ("oc", "occitan"), - ("ka", "georgian"), - ("be", "belarusian"), - ("tg", "tajik"), - ("sd", "sindhi"), - ("gu", "gujarati"), - ("am", "amharic"), - ("yi", "yiddish"), - ("lo", "lao"), - ("uz", "uzbek"), - ("fo", "faroese"), - ("ht", "haitian creole"), - ("ps", "pashto"), - ("tk", "turkmen"), - ("nn", "nynorsk"), - ("mt", "maltese"), - ("sa", "sanskrit"), - ("lb", "luxembourgish"), - ("my", "myanmar"), - ("bo", "tibetan"), - ("tl", "tagalog"), - ("mg", "malagasy"), - ("as", "assamese"), - ("tt", "tatar"), - ("haw", "hawaiian"), - ("ln", "lingala"), - ("ha", "hausa"), - ("ba", "bashkir"), - ("jw", "javanese"), - ("su", "sundanese"), -] \ No newline at end of file +# The supported languages are stored in the 'LANGUAGES' dictionary in the format of language code and language name. +# For example, the first element in the 'LANGUAGES' dictionary is ('en', 'english'). + +# To add support for a new model, we also need to update the `supportedLanguageList` array in the frontend TranscriptionLanguages.js file. +# https://github.com/learningequality/studio/blob/unstable/contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js + +import json +import le_utils.resources as resources + +WHISPER_LANGUAGES = { + "en": "english", + "zh": "chinese", + "de": "german", + "es": "spanish", + "ru": "russian", + "ko": "korean", + "fr": "french", + "ja": "japanese", + "pt": "portuguese", + "tr": "turkish", + "pl": "polish", + "ca": "catalan", + "nl": "dutch", + "ar": "arabic", + "sv": "swedish", + "it": "italian", + "id": "indonesian", + "hi": "hindi", + "fi": "finnish", + "vi": "vietnamese", + "he": "hebrew", + "uk": "ukrainian", + "el": "greek", + "ms": "malay", + "cs": "czech", + "ro": "romanian", + "da": "danish", + "hu": "hungarian", + "ta": "tamil", + "no": "norwegian", + "th": "thai", + "ur": "urdu", + "hr": "croatian", + "bg": "bulgarian", + "lt": "lithuanian", + "la": "latin", + "mi": "maori", + "ml": "malayalam", + "cy": "welsh", + "sk": "slovak", + "te": "telugu", + "fa": "persian", + "lv": "latvian", + "bn": "bengali", + "sr": "serbian", + "az": "azerbaijani", + "sl": "slovenian", + "kn": "kannada", + "et": "estonian", + "mk": "macedonian", + "br": "breton", + "eu": "basque", + "is": "icelandic", + "hy": "armenian", + "ne": "nepali", + "mn": "mongolian", + "bs": "bosnian", + "kk": "kazakh", + "sq": "albanian", + "sw": "swahili", + "gl": "galician", + "mr": "marathi", + "pa": "punjabi", + "si": "sinhala", + "km": "khmer", + "sn": "shona", + "yo": "yoruba", + "so": "somali", + "af": "afrikaans", + "oc": "occitan", + "ka": "georgian", + "be": "belarusian", + "tg": "tajik", + "sd": "sindhi", + "gu": "gujarati", + "am": "amharic", + "yi": "yiddish", + "lo": "lao", + "uz": "uzbek", + "fo": "faroese", + "ht": "haitian creole", + "ps": "pashto", + "tk": "turkmen", + "nn": "nynorsk", + "mt": "maltese", + "sa": "sanskrit", + "lb": "luxembourgish", + "my": "myanmar", + "bo": "tibetan", + "tl": "tagalog", + "mg": "malagasy", + "as": "assamese", + "tt": "tatar", + "haw": "hawaiian", + "ln": "lingala", + "ha": "hausa", + "ba": "bashkir", + "jw": "javanese", + "su": "sundanese", +} + +def _load_kolibri_languages(): + """Load Kolibri languages from JSON file and return the language codes as a list.""" + filepath = resources.__path__[0] + kolibri_languages = [] + with open(f'{filepath}/languagelookup.json') as f: + kolibri_languages = list(json.load(f).keys()) + return kolibri_languages + +def _load_model_languages(languages): + """Load languages supported by the speech-to-text model.""" + return list(languages.keys()) + +def create_captions_languages(): + """Create the intersection of transcription model and Kolibri languages.""" + kolibri_set = set(_load_kolibri_languages()) + model_set = set(_load_model_languages(languages=WHISPER_LANGUAGES)) + return list(kolibri_set.intersection(model_set)) + +CAPTIONS_LANGUAGES = create_captions_languages() diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 91401ed102..dc55fe2485 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -22,7 +22,6 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index a99608b4a8..376542d8bd 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -1,15 +1,69 @@ +import { CaptionFile, CaptionCues } from 'shared/data/resources'; +import { GENERATING } from 'shared/data/constants' -export async function addCaptionFile({ commit }, { file_id, language, nodeId }) { - const captionFile = { - file_id: file_id, - language: language +export async function loadCaptionFiles(commit, params) { + const captionFiles = await CaptionFile.where(params); // We update the IndexedDB resource + commit('ADD_CAPTIONFILES', { captionFiles, nodeIds: params.contentnode_id }); // Now we update the vuex state + return captionFiles; +} + +export async function loadCaptionCues({ commit }, { caption_file_id }) { + const cues = await CaptionCues.where({ caption_file_id }); + commit('ADD_CAPTIONCUES', cues); + return cues; +} + +export async function loadCaptions({ commit, rootGetters }, params) { + const isAIFeatureEnabled = rootGetters['currentChannel/isAIFeatureEnabled']; + if (!isAIFeatureEnabled) return; + + // If a new file is uploaded, the contentnode_id will be string + if (typeof params.contentnode_id === 'string') { + params.contentnode_id = [params.contentnode_id]; + } + const nodeIdsToLoad = []; + for (const nodeId of params.contentnode_id) { + const node = rootGetters['contentNode/getContentNode'](nodeId); + if (node && (node.kind === 'video' || node.kind === 'audio')) { + nodeIdsToLoad.push(nodeId); // already in vuex + } else if (!node) { + nodeIdsToLoad.push(nodeId); // Assume that its audio/video } + } + + const captionFiles = await loadCaptionFiles(commit, { + contentnode_id: nodeIdsToLoad, + }); + + // If there is no Caption File for this contentnode + // Don't request for the cues + if (captionFiles.length === 0) return; + // TODO: call loadCaptionCues -> to be done after + // I finish saving captionFiles in indexedDB When + // CTA is called. So I have captions saved in the backend. +} + +export async function addCaptionFile({ state, commit }, { id, file_id, language, nodeId }) { + const captionFile = { + id: id, + file_id: file_id, + language: language, + }; + // The file_id and language should be unique together in the vuex state. This check avoids duplicating existing caption data already loaded into vuex. + const alreadyExists = state.captionFilesMap[nodeId] + ? Object.values(state.captionFilesMap[nodeId]).find( + file => file.language === captionFile.language && file.file_id === captionFile.file_id + ) + : null; + + if (!alreadyExists) { + // new created file will enqueue generate caption celery task + captionFile[GENERATING] = true; return CaptionFile.add(captionFile).then(id => { - captionFile.id = id; - console.log(captionFile, nodeId); - commit('ADD_CAPTIONFILE', { - captionFile, - nodeId - }); - }) -} \ No newline at end of file + commit('ADD_CAPTIONFILE', { + captionFile, + nodeId, + }); + }); + } +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js index 969306dfa3..6d87474e1d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js @@ -1,4 +1,6 @@ import Vue from "vue"; +import { GENERATING } from 'shared/data/constants' +// import { applyMods } from 'shared/data/applyRemoteChanges'; /* Mutations for Caption File */ export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { @@ -14,15 +16,11 @@ export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { Vue.set(state.captionFilesMap[nodeId], captionFile.id, {}); } - // Check if the file_id and language combination already exists - // const key = `${captionFile.file_id}_${captionFile.language}`; - // if(state.captionFilesMap[nodeId][captionFile.id]) { - // } - // Finally, set the file_id and language for that pk Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'id', captionFile.id); Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'file_id', captionFile.file_id); Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'language', captionFile.language); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], GENERATING, captionFile[GENERATING]) } export function ADD_CAPTIONFILES(state, { captionFiles, nodeIds }) { @@ -46,3 +44,15 @@ export function ADD_CAPTIONCUES(state, { data } = []) { }) } } + +export function UPDATE_CAPTIONFILE_FROM_INDEXEDDB(state, { id, ...mods }) { + if(!id) return; + for (const nodeId in state.captionFilesMap) { + if (state.captionFilesMap[nodeId][id]) { + Vue.set(state.captionFilesMap[nodeId][id], GENERATING, mods[GENERATING]); + // updateCaptionCuesMaps(state, state.captionCuesMap[nodeId][id]); + break; + } + } + console.log('done'); +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/data/constants.js b/contentcuration/contentcuration/frontend/shared/data/constants.js index f957130203..24c93a98ef 100644 --- a/contentcuration/contentcuration/frontend/shared/data/constants.js +++ b/contentcuration/contentcuration/frontend/shared/data/constants.js @@ -70,6 +70,7 @@ export const RELATIVE_TREE_POSITIONS_LOOKUP = invert(RELATIVE_TREE_POSITIONS); export const COPYING_FLAG = '__COPYING'; export const TASK_ID = '__TASK_ID'; export const LAST_FETCHED = '__last_fetch'; +export const GENERATING = '__generating_captions'; // This constant is used for saving/retrieving a current // user object from the session table diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 39cf44f17f..d05f8ba109 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -19,6 +19,7 @@ import { RELATIVE_TREE_POSITIONS, TABLE_NAMES, COPYING_FLAG, + GENERATING, TASK_ID, CURRENT_USER, MAX_REV_KEY, @@ -1024,6 +1025,31 @@ export const CaptionFile = new Resource({ indexFields: ['file_id', 'language'], syncable: true, getChannelId: getChannelFromChannelScope, + + waitForCaptionCueGeneration(id) { + const observable = Dexie.liveQuery(() => { + return this.table + .where('id') + .equals(id) + .filter(f => !f[GENERATING]) + .toArray(); + }); + + return new Promise((resolve, reject) => { + const subscription = observable.subscribe({ + next(result) { + if (result.length > 0 && result[0][GENERATING] === false) { + subscription.unsubscribe(); + resolve(false); + } + }, + error() { + subscription.unsubscribe(); + reject(); + }, + }); + }); + }, }); export const CaptionCues = new Resource({ diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py deleted file mode 100644 index b8176d6a02..0000000000 --- a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-26 17:33 - -import contentcuration.models -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='CaptionFile', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), - ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), - ], - options={ - 'unique_together': {('file_id', 'language')}, - }, - ), - migrations.CreateModel( - name='CaptionCue', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('text', models.TextField()), - ('starttime', models.FloatField()), - ('endtime', models.FloatField()), - ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), - ], - ), - ] diff --git a/contentcuration/contentcuration/tasks.py b/contentcuration/contentcuration/tasks.py index 39f89805ce..b2079a494b 100644 --- a/contentcuration/contentcuration/tasks.py +++ b/contentcuration/contentcuration/tasks.py @@ -137,3 +137,34 @@ def sendcustomemails_task(subject, message, query): text = message.format(current_date=time.strftime("%A, %B %d"), current_time=time.strftime("%H:%M %Z"), **recipient.__dict__) text = render_to_string('registration/custom_email.txt', {'message': text}) recipient.email_user(subject, text, settings.DEFAULT_FROM_EMAIL, ) + +@app.task(name="generatecaptioncues_task") +def generatecaptioncues_task(caption_file_id, channel_id, user_id): + """Start generating the Captions Cues for requested the Caption File""" + + import uuid + from contentcuration.viewsets.caption import CaptionCueSerializer + from contentcuration.viewsets.sync.utils import generate_update_event + from contentcuration.viewsets.sync.constants import CAPTION_FILE + + time.sleep(10) # AI model start generating + + cue = { + "id": uuid.uuid4().hex, + "text":"hello guys", + "starttime": 0, + "endtime": 5, + "caption_file_id": caption_file_id + } + + serializer = CaptionCueSerializer(data=cue) + if serializer.is_valid(): + instance = serializer.save() + Change.create_change(generate_update_event( + caption_file_id, + CAPTION_FILE, + {"__generating_captions": False}, + channel_id=channel_id, + ), applied=True, created_by_id=user_id) + else: + print(serializer.errors) diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 7d2a699230..b14ee5b944 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -87,6 +87,22 @@ def test_create_caption(self): self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) self.assertEqual(caption_file_db.language_id, caption_file["language"]) + def test_enqueue_caption_task(self): + self.client.force_authenticate(user=self.user) + caption_file = { + "file_id": uuid.uuid4().hex, + "language": Language.objects.get(pk="en").pk, + } + + response = self.sync_changes([generate_create_event( + uuid.uuid4().hex, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id, + )],) + self.assertEqual(response.status_code, 200, response.content) + + def test_delete_caption_file(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index aed5948266..1d3e8f02c8 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,30 +1,18 @@ -from le_utils.constants.format_presets import ( - AUDIO, - VIDEO_HIGH_RES, - VIDEO_LOW_RES, -) +import logging + +from le_utils.constants.format_presets import AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES from rest_framework import serializers from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.utils import model_meta - -from contentcuration.models import CaptionCue, CaptionFile, File -from contentcuration.viewsets.base import ValuesViewset +from contentcuration.models import CaptionCue, CaptionFile, Change, File +from contentcuration.tasks import generatecaptioncues_task +from contentcuration.viewsets.base import BulkModelSerializer, ValuesViewset +from contentcuration.viewsets.sync.constants import CAPTION_FILE +from contentcuration.viewsets.sync.utils import generate_update_event -class CaptionCueSerializer(serializers.ModelSerializer): - class Meta: - model = CaptionCue - fields = ["text", "starttime", "endtime", "caption_file_id"] - - def validate(self, attrs): - """Check that the cue's starttime is before the endtime.""" - attrs = super().validate(attrs) - if attrs["starttime"] > attrs["endtime"]: - raise serializers.ValidationError("The cue must finish after start.") - return attrs - +class CaptionCueSerializer(BulkModelSerializer): class Meta: model = CaptionCue fields = ["text", "starttime", "endtime", "caption_file_id"] @@ -38,10 +26,10 @@ def validate(self, attrs): def to_internal_value(self, data): """ - Copies the caption_file_id from the request data + Copies the caption_file_id from the request data to the internal representation before validation. - - Without this, the caption_file_id would be lost + + Without this, the caption_file_id would be lost if validation fails, leading to errors. """ caption_file_id = data.get("caption_file_id") @@ -53,24 +41,12 @@ def to_internal_value(self, data): -class CaptionFileSerializer(serializers.ModelSerializer): +class CaptionFileSerializer(BulkModelSerializer): caption_cue = CaptionCueSerializer(many=True, required=False) class Meta: model = CaptionFile - fields = ["file_id", "language", "caption_cue"] - - @classmethod - def id_attr(cls): - """ - Returns the primary key name for the model class. - - Checks Meta.update_lookup_field to allow customizable - primary key names. Falls back to using the default "id". - """ - ModelClass = cls.Meta.model - info = model_meta.get_field_info(ModelClass) - return getattr(cls.Meta, "update_lookup_field", info.pk.name) + fields = ["id", "file_id", "language", "caption_cue"] class CaptionViewSet(ValuesViewset): @@ -93,7 +69,7 @@ def get_queryset(self): language = self.request.GET.get("language") if contentnode_ids: - contentnode_ids = contentnode_ids.split(',') + contentnode_ids = contentnode_ids.split(",") file_ids = File.objects.filter( preset_id__in=[AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES], contentnode_id__in=contentnode_ids, @@ -107,6 +83,32 @@ def get_queryset(self): return queryset + def perform_create(self, serializer, change=None): + instance = serializer.save() + Change.create_change( + generate_update_event( + instance.pk, + CAPTION_FILE, + { + "__generating_captions": True, + }, + channel_id=change['channel_id'] + ), applied=True, created_by_id=self.request.user.id + ) + + # enqueue task of generating captions for the saved CaptionFile instance + try: + # Also sets the generating flag to false <<< Generating Completeted + generatecaptioncues_task.enqueue( + self.request.user, + caption_file_id=instance.pk, + channel_id=change['channel_id'], + user_id=self.request.user.id + ) + + except Exception as e: + logging.error(f"Failed to queue celery task.\nWith the error: {e}") + class CaptionCueViewSet(ValuesViewset): # Handles operations for the CaptionCue model. diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py deleted file mode 100644 index 148a187698..0000000000 --- a/contentcuration/contentcuration/viewsets/captions.py +++ /dev/null @@ -1,27 +0,0 @@ -from rest_framework import serializers, status -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -# from contentcuration.models import GeneratedCaptions - - -class GeneratedCaptionsSerializer(serializers.ModelSerializer): - class Meta: - # model = GeneratedCaptions - fields = ['id', 'generated_captions', 'language'] - -class CaptionViewSet(ModelViewSet): - # queryset = GeneratedCaptions.objects.all() - serializer_class = GeneratedCaptionsSerializer - - def create(self, request): - # handles the creation operation and return serialized data - pass - - def update(self, request): - # handles the updating of an existing `GeneratedCaption` instance. - pass - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file From 0c892a8b5e341b7b680c27af83b5c908b8dfe5c3 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Wed, 16 Aug 2023 00:09:11 +0530 Subject: [PATCH 067/257] fix merge conflict --- yarn.lock | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3dd4caf8aa..f7d06ee226 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13871,12 +13871,6 @@ workbox-core@7.0.0: resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.0.0.tgz#dec114ec923cc2adc967dd9be1b8a0bed50a3545" integrity sha512-81JkAAZtfVP8darBpfRTovHg8DGAVrKFgHpOArZbdFd78VqHr5Iw65f2guwjE2NlCFbPFDoez3D3/6ZvhI/rwQ== -<<<<<<< HEAD -workbox-expiration@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-7.0.0.tgz#3d90bcf2a7577241de950f89784f6546b66c2baa" - integrity sha512-MLK+fogW+pC3IWU9SFE+FRStvDVutwJMR5if1g7oBJx3qwmO69BNoJQVaMXq41R0gg3MzxVfwOGKx3i9P6sOLQ== -======= workbox-core@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.0.0.tgz#dec114ec923cc2adc967dd9be1b8a0bed50a3545" @@ -13886,7 +13880,6 @@ workbox-expiration@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.5.4.tgz#501056f81e87e1d296c76570bb483ce5e29b4539" integrity sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ== ->>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: idb "^7.0.1" workbox-core "7.0.0" @@ -13908,28 +13901,15 @@ workbox-navigation-preload@7.0.0: dependencies: workbox-core "7.0.0" -<<<<<<< HEAD -workbox-precaching@7.0.0, workbox-precaching@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.0.0.tgz#3979ba8033aadf3144b70e9fe631d870d5fbaa03" - integrity sha512-EC0vol623LJqTJo1mkhD9DZmMP604vHqni3EohhQVwhJlTgyKyOkMrZNy5/QHfOby+39xqC01gv4LjOm4HSfnA== -======= workbox-precaching@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.5.4.tgz#740e3561df92c6726ab5f7471e6aac89582cab72" integrity sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg== ->>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" workbox-routing "7.0.0" workbox-strategies "7.0.0" -<<<<<<< HEAD -workbox-range-requests@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-7.0.0.tgz#97511901e043df27c1aa422adcc999a7751f52ed" - integrity sha512-SxAzoVl9j/zRU9OT5+IQs7pbJBOUOlriB8Gn9YMvi38BNZRbM+RvkujHMo8FOe9IWrqqwYgDFBfv6sk76I1yaQ== -======= workbox-precaching@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.0.0.tgz#3979ba8033aadf3144b70e9fe631d870d5fbaa03" @@ -13943,7 +13923,6 @@ workbox-range-requests@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz#86b3d482e090433dab38d36ae031b2bb0bd74399" integrity sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg== ->>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" @@ -13966,12 +13945,6 @@ workbox-routing@7.0.0: dependencies: workbox-core "7.0.0" -<<<<<<< HEAD -workbox-strategies@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.0.0.tgz#dcba32b3f3074476019049cc490fe1a60ea73382" - integrity sha512-dg3qJU7tR/Gcd/XXOOo7x9QoCI9nk74JopaJaYAQ+ugLi57gPsXycVdBnYbayVj34m6Y8ppPwIuecrzkpBVwbA== -======= workbox-routing@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-7.0.0.tgz#6668438a06554f60645aedc77244a4fe3a91e302" @@ -13983,16 +13956,9 @@ workbox-strategies@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.5.4.tgz#4edda035b3c010fc7f6152918370699334cd204d" integrity sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw== ->>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" -<<<<<<< HEAD -workbox-streams@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-7.0.0.tgz#36722aecd04785f88b6f709e541c094fc658c0f9" - integrity sha512-moVsh+5to//l6IERWceYKGiftc+prNnqOp2sgALJJFbnNVpTXzKISlTIsrWY+ogMqt+x1oMazIdHj25kBSq/HQ== -======= workbox-strategies@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.0.0.tgz#dcba32b3f3074476019049cc490fe1a60ea73382" @@ -14004,7 +13970,6 @@ workbox-streams@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.5.4.tgz#1cb3c168a6101df7b5269d0353c19e36668d7d69" integrity sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg== ->>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" workbox-routing "7.0.0" From 2fe1f432f86f748aafe275eed30a6f335c5b58ea Mon Sep 17 00:00:00 2001 From: akash5100 Date: Wed, 16 Aug 2023 00:18:43 +0530 Subject: [PATCH 068/257] Revert "fix merge conflict" This reverts commit 0c892a8b5e341b7b680c27af83b5c908b8dfe5c3. --- yarn.lock | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/yarn.lock b/yarn.lock index f7d06ee226..3dd4caf8aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13871,6 +13871,12 @@ workbox-core@7.0.0: resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.0.0.tgz#dec114ec923cc2adc967dd9be1b8a0bed50a3545" integrity sha512-81JkAAZtfVP8darBpfRTovHg8DGAVrKFgHpOArZbdFd78VqHr5Iw65f2guwjE2NlCFbPFDoez3D3/6ZvhI/rwQ== +<<<<<<< HEAD +workbox-expiration@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-7.0.0.tgz#3d90bcf2a7577241de950f89784f6546b66c2baa" + integrity sha512-MLK+fogW+pC3IWU9SFE+FRStvDVutwJMR5if1g7oBJx3qwmO69BNoJQVaMXq41R0gg3MzxVfwOGKx3i9P6sOLQ== +======= workbox-core@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.0.0.tgz#dec114ec923cc2adc967dd9be1b8a0bed50a3545" @@ -13880,6 +13886,7 @@ workbox-expiration@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.5.4.tgz#501056f81e87e1d296c76570bb483ce5e29b4539" integrity sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ== +>>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: idb "^7.0.1" workbox-core "7.0.0" @@ -13901,15 +13908,28 @@ workbox-navigation-preload@7.0.0: dependencies: workbox-core "7.0.0" +<<<<<<< HEAD +workbox-precaching@7.0.0, workbox-precaching@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.0.0.tgz#3979ba8033aadf3144b70e9fe631d870d5fbaa03" + integrity sha512-EC0vol623LJqTJo1mkhD9DZmMP604vHqni3EohhQVwhJlTgyKyOkMrZNy5/QHfOby+39xqC01gv4LjOm4HSfnA== +======= workbox-precaching@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.5.4.tgz#740e3561df92c6726ab5f7471e6aac89582cab72" integrity sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg== +>>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" workbox-routing "7.0.0" workbox-strategies "7.0.0" +<<<<<<< HEAD +workbox-range-requests@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-7.0.0.tgz#97511901e043df27c1aa422adcc999a7751f52ed" + integrity sha512-SxAzoVl9j/zRU9OT5+IQs7pbJBOUOlriB8Gn9YMvi38BNZRbM+RvkujHMo8FOe9IWrqqwYgDFBfv6sk76I1yaQ== +======= workbox-precaching@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.0.0.tgz#3979ba8033aadf3144b70e9fe631d870d5fbaa03" @@ -13923,6 +13943,7 @@ workbox-range-requests@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz#86b3d482e090433dab38d36ae031b2bb0bd74399" integrity sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg== +>>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" @@ -13945,6 +13966,12 @@ workbox-routing@7.0.0: dependencies: workbox-core "7.0.0" +<<<<<<< HEAD +workbox-strategies@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.0.0.tgz#dcba32b3f3074476019049cc490fe1a60ea73382" + integrity sha512-dg3qJU7tR/Gcd/XXOOo7x9QoCI9nk74JopaJaYAQ+ugLi57gPsXycVdBnYbayVj34m6Y8ppPwIuecrzkpBVwbA== +======= workbox-routing@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-7.0.0.tgz#6668438a06554f60645aedc77244a4fe3a91e302" @@ -13956,9 +13983,16 @@ workbox-strategies@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.5.4.tgz#4edda035b3c010fc7f6152918370699334cd204d" integrity sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw== +>>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" +<<<<<<< HEAD +workbox-streams@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-7.0.0.tgz#36722aecd04785f88b6f709e541c094fc658c0f9" + integrity sha512-moVsh+5to//l6IERWceYKGiftc+prNnqOp2sgALJJFbnNVpTXzKISlTIsrWY+ogMqt+x1oMazIdHj25kBSq/HQ== +======= workbox-strategies@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.0.0.tgz#dcba32b3f3074476019049cc490fe1a60ea73382" @@ -13970,6 +14004,7 @@ workbox-streams@6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.5.4.tgz#1cb3c168a6101df7b5269d0353c19e36668d7d69" integrity sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg== +>>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" workbox-routing "7.0.0" From 848895ea8e58198829b6cf0dd811eb31e4c762a5 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Wed, 16 Aug 2023 00:26:27 +0530 Subject: [PATCH 069/257] fix merge conflict --- package.json | 2 +- yarn.lock | 63 ---------------------------------------------------- 2 files changed, 1 insertion(+), 64 deletions(-) diff --git a/package.json b/package.json index 222df6d32c..7ac683e762 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "vuetify": "^1.5.24", "vuex": "^3.0.1", "workbox-precaching": "^7.0.0", - "workbox-window": "^6.5.4" + "workbox-window": "^7.0.0" }, "devDependencies": { "@vue/test-utils": "1.0.0-beta.29", diff --git a/yarn.lock b/yarn.lock index 3dd4caf8aa..43ba4eb866 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13871,22 +13871,10 @@ workbox-core@7.0.0: resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.0.0.tgz#dec114ec923cc2adc967dd9be1b8a0bed50a3545" integrity sha512-81JkAAZtfVP8darBpfRTovHg8DGAVrKFgHpOArZbdFd78VqHr5Iw65f2guwjE2NlCFbPFDoez3D3/6ZvhI/rwQ== -<<<<<<< HEAD workbox-expiration@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-7.0.0.tgz#3d90bcf2a7577241de950f89784f6546b66c2baa" integrity sha512-MLK+fogW+pC3IWU9SFE+FRStvDVutwJMR5if1g7oBJx3qwmO69BNoJQVaMXq41R0gg3MzxVfwOGKx3i9P6sOLQ== -======= -workbox-core@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.0.0.tgz#dec114ec923cc2adc967dd9be1b8a0bed50a3545" - integrity sha512-81JkAAZtfVP8darBpfRTovHg8DGAVrKFgHpOArZbdFd78VqHr5Iw65f2guwjE2NlCFbPFDoez3D3/6ZvhI/rwQ== - -workbox-expiration@6.5.4: - version "6.5.4" - resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.5.4.tgz#501056f81e87e1d296c76570bb483ce5e29b4539" - integrity sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ== ->>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: idb "^7.0.1" workbox-core "7.0.0" @@ -13908,42 +13896,19 @@ workbox-navigation-preload@7.0.0: dependencies: workbox-core "7.0.0" -<<<<<<< HEAD workbox-precaching@7.0.0, workbox-precaching@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.0.0.tgz#3979ba8033aadf3144b70e9fe631d870d5fbaa03" integrity sha512-EC0vol623LJqTJo1mkhD9DZmMP604vHqni3EohhQVwhJlTgyKyOkMrZNy5/QHfOby+39xqC01gv4LjOm4HSfnA== -======= -workbox-precaching@6.5.4: - version "6.5.4" - resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.5.4.tgz#740e3561df92c6726ab5f7471e6aac89582cab72" - integrity sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg== ->>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" workbox-routing "7.0.0" workbox-strategies "7.0.0" -<<<<<<< HEAD workbox-range-requests@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-7.0.0.tgz#97511901e043df27c1aa422adcc999a7751f52ed" integrity sha512-SxAzoVl9j/zRU9OT5+IQs7pbJBOUOlriB8Gn9YMvi38BNZRbM+RvkujHMo8FOe9IWrqqwYgDFBfv6sk76I1yaQ== -======= -workbox-precaching@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.0.0.tgz#3979ba8033aadf3144b70e9fe631d870d5fbaa03" - integrity sha512-EC0vol623LJqTJo1mkhD9DZmMP604vHqni3EohhQVwhJlTgyKyOkMrZNy5/QHfOby+39xqC01gv4LjOm4HSfnA== - dependencies: - workbox-core "7.0.0" - workbox-routing "7.0.0" - workbox-strategies "7.0.0" - -workbox-range-requests@6.5.4: - version "6.5.4" - resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz#86b3d482e090433dab38d36ae031b2bb0bd74399" - integrity sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg== ->>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" @@ -13966,45 +13931,17 @@ workbox-routing@7.0.0: dependencies: workbox-core "7.0.0" -<<<<<<< HEAD workbox-strategies@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.0.0.tgz#dcba32b3f3074476019049cc490fe1a60ea73382" integrity sha512-dg3qJU7tR/Gcd/XXOOo7x9QoCI9nk74JopaJaYAQ+ugLi57gPsXycVdBnYbayVj34m6Y8ppPwIuecrzkpBVwbA== -======= -workbox-routing@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-7.0.0.tgz#6668438a06554f60645aedc77244a4fe3a91e302" - integrity sha512-8YxLr3xvqidnbVeGyRGkaV4YdlKkn5qZ1LfEePW3dq+ydE73hUUJJuLmGEykW3fMX8x8mNdL0XrWgotcuZjIvA== dependencies: workbox-core "7.0.0" -workbox-strategies@6.5.4: - version "6.5.4" - resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.5.4.tgz#4edda035b3c010fc7f6152918370699334cd204d" - integrity sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw== ->>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c - dependencies: - workbox-core "7.0.0" - -<<<<<<< HEAD workbox-streams@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-7.0.0.tgz#36722aecd04785f88b6f709e541c094fc658c0f9" integrity sha512-moVsh+5to//l6IERWceYKGiftc+prNnqOp2sgALJJFbnNVpTXzKISlTIsrWY+ogMqt+x1oMazIdHj25kBSq/HQ== -======= -workbox-strategies@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.0.0.tgz#dcba32b3f3074476019049cc490fe1a60ea73382" - integrity sha512-dg3qJU7tR/Gcd/XXOOo7x9QoCI9nk74JopaJaYAQ+ugLi57gPsXycVdBnYbayVj34m6Y8ppPwIuecrzkpBVwbA== - dependencies: - workbox-core "7.0.0" - -workbox-streams@6.5.4: - version "6.5.4" - resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.5.4.tgz#1cb3c168a6101df7b5269d0353c19e36668d7d69" - integrity sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg== ->>>>>>> 4f88131ae120a7eac52daacd74dda8258b36b48c dependencies: workbox-core "7.0.0" workbox-routing "7.0.0" From a1dc9c6f3b8c7b51c708e988f5475831cc5cbaa5 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 23 May 2023 16:51:29 +0530 Subject: [PATCH 070/257] created captionviewset --- .../migrations/0143_generatedcaptions.py | 21 +++++++++++++++ contentcuration/contentcuration/models.py | 6 +++++ contentcuration/contentcuration/urls.py | 2 ++ .../contentcuration/viewsets/captions.py | 27 +++++++++++++++++++ .../contentcuration/viewsets/sync/base.py | 3 +++ .../viewsets/sync/constants.py | 2 ++ 6 files changed, 61 insertions(+) create mode 100644 contentcuration/contentcuration/migrations/0143_generatedcaptions.py create mode 100644 contentcuration/contentcuration/viewsets/captions.py diff --git a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py b/contentcuration/contentcuration/migrations/0143_generatedcaptions.py new file mode 100644 index 0000000000..0502d7e8bc --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_generatedcaptions.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.14 on 2023-05-23 11:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='GeneratedCaptions', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('generated_captions', models.JSONField()), + ('language', models.CharField(max_length=10)), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 86a400931c..c1c1c8f7c6 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2060,6 +2060,12 @@ def __str__(self): return self.ietf_name() +class GeneratedCaptions(models.Model): + id = models.AutoField(primary_key=True) + generated_captions = models.JSONField() + language = models.CharField(max_length=10) + + ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index bb03f3876e..ad3bb991c4 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,6 +32,7 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet +from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.channel import AdminChannelViewSet from contentcuration.viewsets.channel import CatalogViewSet @@ -55,6 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") +router.register(r'captions', CaptionViewSet) router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py new file mode 100644 index 0000000000..84a5e3981c --- /dev/null +++ b/contentcuration/contentcuration/viewsets/captions.py @@ -0,0 +1,27 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from contentcuration.models import GeneratedCaptions + + +class GeneratedCaptionsSerializer(serializers.ModelSerializer): + class Meta: + model = GeneratedCaptions + fields = ['id', 'generated_captions', 'language'] + +class CaptionViewSet(ModelViewSet): + queryset = GeneratedCaptions.objects.all() + serializer_class = GeneratedCaptionsSerializer + + def create(self, request): + # handles the creation operation and return serialized data + pass + + def update(self, request): + # handles the updating of an existing `GeneratedCaption` instance. + pass + + def destroy(self, request): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index f11a8f4729..44e3861050 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,6 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet +from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet @@ -14,6 +15,7 @@ from contentcuration.viewsets.invitation import InvitationViewSet from contentcuration.viewsets.sync.constants import ASSESSMENTITEM from contentcuration.viewsets.sync.constants import BOOKMARK +from contentcuration.viewsets.sync.constants import CAPTION from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import CHANNELSET from contentcuration.viewsets.sync.constants import CLIPBOARD @@ -73,6 +75,7 @@ def __init__(self, change_type, viewset_class): (EDITOR_M2M, ChannelUserViewSet), (VIEWER_M2M, ChannelUserViewSet), (SAVEDSEARCH, SavedSearchViewSet), + (CAPTION, CaptionViewSet), ] ) diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index 84c2b5aad7..ffc3227873 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,6 +22,7 @@ # Client-side table constants BOOKMARK = "bookmark" +CAPTION = "captions" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" @@ -39,6 +40,7 @@ ALL_TABLES = set( [ BOOKMARK, + CAPTION, CHANNEL, CLIPBOARD, CONTENTNODE, From c85c2457942e3e4a20c81466a0c2f5f0f1a513d4 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Mon, 19 Jun 2023 12:40:39 +0530 Subject: [PATCH 071/257] Adds captions modal with visibility controlled by featureflag --- .../CaptionsEditor/CaptionsEditor.vue | 19 +++++++++ .../channelEdit/components/edit/EditView.vue | 39 +++++++++++++++--- .../frontend/channelEdit/constants.js | 1 + .../vuex/currentChannel/getters.js | 4 ++ .../frontend/shared/data/constants.js | 1 + .../frontend/shared/data/resources.js | 4 ++ ...3_generatedcaptions.py => 0143_caption.py} | 10 +++-- contentcuration/contentcuration/models.py | 10 +++-- contentcuration/contentcuration/urls.py | 4 +- .../contentcuration/viewsets/caption.py | 40 +++++++++++++++++++ .../contentcuration/viewsets/captions.py | 27 ------------- .../contentcuration/viewsets/sync/base.py | 2 +- .../viewsets/sync/constants.py | 2 +- 13 files changed, 120 insertions(+), 43 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue rename contentcuration/contentcuration/migrations/{0143_generatedcaptions.py => 0143_caption.py} (53%) create mode 100644 contentcuration/contentcuration/viewsets/caption.py delete mode 100644 contentcuration/contentcuration/viewsets/captions.py diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue new file mode 100644 index 0000000000..33f25de838 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue index 4a1e0cebb5..1b8d688fd4 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue @@ -62,6 +62,16 @@ {{ relatedResourcesCount }} + + + + {{ $tr(tabs.CAPTIONS) }} + @@ -82,6 +92,7 @@ + + + + + @@ -104,6 +120,7 @@ import { TabNames } from '../../constants'; import AssessmentTab from '../../components/AssessmentTab/AssessmentTab'; + import CaptionsEditor from '../../components/CaptionsEditor/CaptionsEditor' import RelatedResourcesTab from '../../components/RelatedResourcesTab/RelatedResourcesTab'; import DetailsTabView from './DetailsTabView'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -113,11 +130,12 @@ export default { name: 'EditView', components: { - DetailsTabView, - AssessmentTab, - RelatedResourcesTab, - Tabs, - ToolBar, + AssessmentTab, + CaptionsEditor, + DetailsTabView, + RelatedResourcesTab, + Tabs, + ToolBar, }, props: { nodeIds: { @@ -143,6 +161,7 @@ 'getImmediateRelatedResourcesCount', ]), ...mapGetters('assessmentItem', ['getAssessmentItemsAreValid', 'getAssessmentItemsCount']), + ...mapGetters('currentChannel', ['isAIFeatureEnabled']), firstNode() { return this.nodes.length ? this.nodes[0] : null; }, @@ -167,6 +186,14 @@ showRelatedResourcesTab() { return this.oneSelected && this.firstNode && this.firstNode.kind !== 'topic'; }, + showCaptions() { + return ( + this.oneSelected && + this.firstNode && + (this.firstNode.kind === 'video' || this.firstNode.kind === 'audio') && + this.isAIFeatureEnabled + ) + }, countText() { const totals = reduce( this.nodes, @@ -260,6 +287,8 @@ questions: 'Questions', /** @see TabNames.RELATED */ related: 'Related', + /** @see TabNames.CAPTIONS */ + captions: 'Captions', /* eslint-enable kolibri/vue-no-unused-translations */ noItemsToEditText: 'Please select resources or folders to edit', invalidFieldsToolTip: 'Some required information is missing', diff --git a/contentcuration/contentcuration/frontend/channelEdit/constants.js b/contentcuration/contentcuration/frontend/channelEdit/constants.js index 6512e9e9b4..baa7c25551 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/constants.js +++ b/contentcuration/contentcuration/frontend/channelEdit/constants.js @@ -55,6 +55,7 @@ export const TabNames = { PREVIEW: 'preview', QUESTIONS: 'questions', RELATED: 'related', + CAPTIONS: 'captions' }; export const modes = { diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js index ead653c2e4..3aeefe506b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/currentChannel/getters.js @@ -14,6 +14,10 @@ export function canEdit(state, getters, rootState, rootGetters) { ); } +export function isAIFeatureEnabled(state, getters, rootState, rootGetters) { + return rootGetters.featureFlags.ai_feature || false; +} + // Allow some extra actions for ricecooker channels export function canManage(state, getters, rootState, rootGetters) { return getters.currentChannel && (getters.currentChannel.edit || rootGetters.isAdmin); diff --git a/contentcuration/contentcuration/frontend/shared/data/constants.js b/contentcuration/contentcuration/frontend/shared/data/constants.js index 709b251d1f..bc5acc4eea 100644 --- a/contentcuration/contentcuration/frontend/shared/data/constants.js +++ b/contentcuration/contentcuration/frontend/shared/data/constants.js @@ -46,6 +46,7 @@ export const TABLE_NAMES = { TASK: 'task', CHANGES_TABLE, BOOKMARK: 'bookmark', + CAPTION: 'caption' }; /** diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 4a7101f307..399489f9a2 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1017,6 +1017,10 @@ export const Bookmark = new Resource({ getUserId: getUserIdFromStore, }); +export const Caption = new Resource({ + // TODO +}) + export const Channel = new Resource({ tableName: TABLE_NAMES.CHANNEL, urlName: 'channel', diff --git a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py b/contentcuration/contentcuration/migrations/0143_caption.py similarity index 53% rename from contentcuration/contentcuration/migrations/0143_generatedcaptions.py rename to contentcuration/contentcuration/migrations/0143_caption.py index 0502d7e8bc..3d6e0e769c 100644 --- a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py +++ b/contentcuration/contentcuration/migrations/0143_caption.py @@ -1,6 +1,8 @@ -# Generated by Django 3.2.14 on 2023-05-23 11:00 +# Generated by Django 3.2.14 on 2023-06-15 06:13 +import contentcuration.models from django.db import migrations, models +import uuid class Migration(migrations.Migration): @@ -11,10 +13,10 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='GeneratedCaptions', + name='Caption', fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('generated_captions', models.JSONField()), + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('caption', models.JSONField()), ('language', models.CharField(max_length=10)), ], ), diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index c1c1c8f7c6..83c2badce9 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2060,10 +2060,14 @@ def __str__(self): return self.ietf_name() -class GeneratedCaptions(models.Model): - id = models.AutoField(primary_key=True) - generated_captions = models.JSONField() +class Caption(models.Model): + """ + Model to store captions and support intermediary changes + """ + id = UUIDField(primary_key=True, default=uuid.uuid4) + caption = models.JSONField() language = models.CharField(max_length=10) + # file_id = models.CharField(unique=True, max_length=32) ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index ad3bb991c4..581505918f 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,8 +32,8 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet -from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet +from contentcuration.viewsets.caption import CaptionViewSet from contentcuration.viewsets.channel import AdminChannelViewSet from contentcuration.viewsets.channel import CatalogViewSet from contentcuration.viewsets.channel import ChannelViewSet @@ -56,7 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") -router.register(r'captions', CaptionViewSet) +router.register(r'caption', CaptionViewSet) router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py new file mode 100644 index 0000000000..2dd2062bc7 --- /dev/null +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -0,0 +1,40 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from contentcuration.models import Caption + + +class CaptionSerializer(serializers.ModelSerializer): + class Meta: + model = Caption + fields = ["id", "caption", "language"] + + +class CaptionViewSet(ModelViewSet): + queryset = Caption.objects.all() + serializer_class = CaptionSerializer + + def create(self, request): + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + self.perform_create(serializer=serializer) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, headers=headers, status=status.HTTP_201_CREATED + ) + + def update(self, request, pk=None): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + if not serializer.is_valid(raise_exception=True): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py deleted file mode 100644 index 84a5e3981c..0000000000 --- a/contentcuration/contentcuration/viewsets/captions.py +++ /dev/null @@ -1,27 +0,0 @@ -from rest_framework import serializers, status -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from contentcuration.models import GeneratedCaptions - - -class GeneratedCaptionsSerializer(serializers.ModelSerializer): - class Meta: - model = GeneratedCaptions - fields = ['id', 'generated_captions', 'language'] - -class CaptionViewSet(ModelViewSet): - queryset = GeneratedCaptions.objects.all() - serializer_class = GeneratedCaptionsSerializer - - def create(self, request): - # handles the creation operation and return serialized data - pass - - def update(self, request): - # handles the updating of an existing `GeneratedCaption` instance. - pass - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index 44e3861050..879c67e123 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,7 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet -from contentcuration.viewsets.captions import CaptionViewSet +from contentcuration.viewsets.caption import CaptionViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index ffc3227873..1576953b50 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,7 +22,7 @@ # Client-side table constants BOOKMARK = "bookmark" -CAPTION = "captions" +CAPTION = "caption" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" From 14996d432279bcbc11e55840365a1d3cebe2abb3 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:29:13 +0530 Subject: [PATCH 072/257] Adds Sync API tests for CaptionFile ViewSet --- .../constants/transcription_languages.py | 106 +++++++++++++ .../migrations/0143_caption.py | 23 --- .../migrations/0143_captioncue_captionfile.py | 37 +++++ contentcuration/contentcuration/models.py | 41 ++++- .../tests/viewsets/test_caption.py | 147 ++++++++++++++++++ .../contentcuration/viewsets/caption.py | 99 ++++++++---- .../contentcuration/viewsets/sync/base.py | 8 +- .../viewsets/sync/constants.py | 6 +- 8 files changed, 403 insertions(+), 64 deletions(-) create mode 100644 contentcuration/contentcuration/constants/transcription_languages.py delete mode 100644 contentcuration/contentcuration/migrations/0143_caption.py create mode 100644 contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py create mode 100644 contentcuration/contentcuration/tests/viewsets/test_caption.py diff --git a/contentcuration/contentcuration/constants/transcription_languages.py b/contentcuration/contentcuration/constants/transcription_languages.py new file mode 100644 index 0000000000..753c91e7d3 --- /dev/null +++ b/contentcuration/contentcuration/constants/transcription_languages.py @@ -0,0 +1,106 @@ +# This file contains a list of transcription languages. +# The list is in the format of (language code, language name). +# For example, the first element in the list is ('en', 'english'). + + +CAPTIONS_LANGUAGES = [ + ("en", "english"), + ("zh", "chinese"), + ("de", "german"), + ("es", "spanish"), + ("ru", "russian"), + ("ko", "korean"), + ("fr", "french"), + ("ja", "japanese"), + ("pt", "portuguese"), + ("tr", "turkish"), + ("pl", "polish"), + ("ca", "catalan"), + ("nl", "dutch"), + ("ar", "arabic"), + ("sv", "swedish"), + ("it", "italian"), + ("id", "indonesian"), + ("hi", "hindi"), + ("fi", "finnish"), + ("vi", "vietnamese"), + ("he", "hebrew"), + ("uk", "ukrainian"), + ("el", "greek"), + ("ms", "malay"), + ("cs", "czech"), + ("ro", "romanian"), + ("da", "danish"), + ("hu", "hungarian"), + ("ta", "tamil"), + ("no", "norwegian"), + ("th", "thai"), + ("ur", "urdu"), + ("hr", "croatian"), + ("bg", "bulgarian"), + ("lt", "lithuanian"), + ("la", "latin"), + ("mi", "maori"), + ("ml", "malayalam"), + ("cy", "welsh"), + ("sk", "slovak"), + ("te", "telugu"), + ("fa", "persian"), + ("lv", "latvian"), + ("bn", "bengali"), + ("sr", "serbian"), + ("az", "azerbaijani"), + ("sl", "slovenian"), + ("kn", "kannada"), + ("et", "estonian"), + ("mk", "macedonian"), + ("br", "breton"), + ("eu", "basque"), + ("is", "icelandic"), + ("hy", "armenian"), + ("ne", "nepali"), + ("mn", "mongolian"), + ("bs", "bosnian"), + ("kk", "kazakh"), + ("sq", "albanian"), + ("sw", "swahili"), + ("gl", "galician"), + ("mr", "marathi"), + ("pa", "punjabi"), + ("si", "sinhala"), + ("km", "khmer"), + ("sn", "shona"), + ("yo", "yoruba"), + ("so", "somali"), + ("af", "afrikaans"), + ("oc", "occitan"), + ("ka", "georgian"), + ("be", "belarusian"), + ("tg", "tajik"), + ("sd", "sindhi"), + ("gu", "gujarati"), + ("am", "amharic"), + ("yi", "yiddish"), + ("lo", "lao"), + ("uz", "uzbek"), + ("fo", "faroese"), + ("ht", "haitian creole"), + ("ps", "pashto"), + ("tk", "turkmen"), + ("nn", "nynorsk"), + ("mt", "maltese"), + ("sa", "sanskrit"), + ("lb", "luxembourgish"), + ("my", "myanmar"), + ("bo", "tibetan"), + ("tl", "tagalog"), + ("mg", "malagasy"), + ("as", "assamese"), + ("tt", "tatar"), + ("haw", "hawaiian"), + ("ln", "lingala"), + ("ha", "hausa"), + ("ba", "bashkir"), + ("jw", "javanese"), + ("su", "sundanese"), +] \ No newline at end of file diff --git a/contentcuration/contentcuration/migrations/0143_caption.py b/contentcuration/contentcuration/migrations/0143_caption.py deleted file mode 100644 index 3d6e0e769c..0000000000 --- a/contentcuration/contentcuration/migrations/0143_caption.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-15 06:13 - -import contentcuration.models -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='Caption', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('caption', models.JSONField()), - ('language', models.CharField(max_length=10)), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py new file mode 100644 index 0000000000..b8176d6a02 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-06-26 17:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 83c2badce9..71dcc0e12b 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,6 +2,7 @@ import json import logging import os +from typing import Any import urllib.parse import uuid from datetime import datetime @@ -68,6 +69,7 @@ from contentcuration.constants import channel_history from contentcuration.constants import completion_criteria from contentcuration.constants import user_history +from contentcuration.constants.transcription_languages import CAPTIONS_LANGUAGES from contentcuration.constants.contentnode import kind_activity_map from contentcuration.db.models.expressions import Array from contentcuration.db.models.functions import ArrayRemove @@ -2060,15 +2062,42 @@ def __str__(self): return self.ietf_name() -class Caption(models.Model): +class CaptionFile(models.Model): """ - Model to store captions and support intermediary changes + Represents a caption file record. + + - file_id: The identifier of related file in Google Cloud Storage. + - language: The language of the caption file. + """ + id = UUIDField(primary_key=True, default=uuid.uuid4) + file_id = UUIDField(default=uuid.uuid4, max_length=36) + language = models.CharField(choices=CAPTIONS_LANGUAGES, max_length=3) + + class Meta: + unique_together = ['file_id', 'language'] + + def __str__(self): + return "{file_id} -> {language}".format(file_id=self.file_id, language=self.language) + + +class CaptionCue(models.Model): + """ + Represents a caption cue in a VTT file. + + - text: The caption text. + - starttime: The start time of the cue in seconds. + - endtime: The end time of the cue in seconds. + - caption_file (Foreign Key): The related caption file. """ id = UUIDField(primary_key=True, default=uuid.uuid4) - caption = models.JSONField() - language = models.CharField(max_length=10) - # file_id = models.CharField(unique=True, max_length=32) - + text = models.TextField(null=False) + starttime = models.FloatField(null=False) + endtime = models.FloatField(null=False) + caption_file = models.ForeignKey(CaptionFile, related_name="caption_cue", on_delete=models.CASCADE) + + def __str__(self): + return "text: {text}, start_time: {starttime}, end_time: {endtime}".format(text=self.text, starttime=self.starttime, endtime=self.endtime) + ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py new file mode 100644 index 0000000000..07ffe9dfa0 --- /dev/null +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -0,0 +1,147 @@ +from __future__ import absolute_import + +import uuid + +from django.urls import reverse + +from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.tests.base import StudioAPITestCase +from contentcuration.tests import testdata +from contentcuration.tests.viewsets.base import SyncTestMixin +from contentcuration.tests.viewsets.base import generate_create_event +from contentcuration.tests.viewsets.base import generate_update_event +from contentcuration.tests.viewsets.base import generate_delete_event +from contentcuration.viewsets.sync.constants import CAPTION_FILE + +# class CRUDTestCase(StudioAPITestCase): + +class SyncTestCase(SyncTestMixin, StudioAPITestCase): + + @property + def caption_file_metadata(self): + return { + "file_id": uuid.uuid4().hex, + "language": "en", + } + + @property + def same_file_different_language_metadata(self): + id = uuid.uuid4().hex + return [ + { + "file_id": id, + "language": "en", + }, + { + "file_id": id, + "language": "ru", + } + ] + + @property + def caption_file_db_metadata(self): + return { + "file_id": uuid.uuid4().hex, + "language": "en", + } + + + def setUp(self): + super(SyncTestCase, self).setUp() + self.channel = testdata.channel() + self.user = testdata.user() + self.channel.editors.add(self.user) + + + def test_create_caption(self): + self.client.force_authenticate(user=self.user) + caption_file = self.caption_file_metadata + + response = self.sync_changes( + [ + generate_create_event( + uuid.uuid4().hex, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + + try: + caption_file_db = CaptionFile.objects.get( + file_id=caption_file["file_id"], + language=caption_file["language"], + ) + except CaptionFile.DoesNotExist: + self.fail("caption file was not created") + + # Check the values of the object in the PostgreSQL + self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) + self.assertEqual(caption_file_db.language, caption_file["language"]) + + def test_delete_caption_file(self): + self.client.force_authenticate(user=self.user) + caption_file = self.caption_file_metadata + pk = uuid.uuid4().hex + response = self.sync_changes( + [ + generate_create_event( + pk, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id + ) + ] + ) + self.assertEqual(response.status_code, 200, response.content) + + # Delete the caption file + response = self.sync_changes( + [ + generate_delete_event( + pk, + CAPTION_FILE, + channel_id=self.channel.id + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + + with self.assertRaises(CaptionFile.DoesNotExist): + caption_file_db = CaptionFile.objects.get( + file_id=caption_file['file_id'], + language=caption_file['language'] + ) + + + def test_delete_file_with_same_file_id_different_language(self): + self.client.force_authenticate(user=self.user) + obj = self.same_file_different_language_metadata + + caption_file_1 = CaptionFile.objects.create( + **obj[0] + ) + caption_file_2 = CaptionFile.objects.create( + **obj[1] + ) + + response = self.sync_changes( + [ + generate_delete_event( + caption_file_2.pk, + CAPTION_FILE, + channel_id=self.channel.id, + ) + ] + ) + + self.assertEqual(response.status_code, 200, response.content) + + with self.assertRaises(CaptionFile.DoesNotExist): + caption_file_db = CaptionFile.objects.get( + file_id=caption_file_2.file_id, + language=caption_file_2.language + ) diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 2dd2062bc7..1a62b191d5 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,40 +1,79 @@ from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from contentcuration.models import Caption +from contentcuration.models import CaptionCue +from contentcuration.models import CaptionFile +from contentcuration.viewsets.base import ValuesViewset + +from contentcuration.viewsets.sync.utils import log_sync_exception + +from django.core.exceptions import ObjectDoesNotExist + + +""" +[x] create file - POST /api/caption?file_id=..&language=.. +[x] delete file - DELETE /api/caption?file_id=..&language=.. + +[] create file cue - POST /api/caption/cue?file_id=..&language=.. +[] update file cue - PATCH /api/caption/cue?file_id=..&language=..&cue_id=.. +[] delete file cue - DELETE /api/caption/cue?file_id=..&language=..&cue_id=.. + +[] get the file cues - GET /api/caption?file_id=..&language=.. +""" + + +class CueSerializer(serializers.ModelSerializer): + class Meta: + model = CaptionCue + fields = ["text", "starttime", "endtime"] class CaptionSerializer(serializers.ModelSerializer): + caption_cue = CueSerializer(many=True, required=False) + class Meta: - model = Caption - fields = ["id", "caption", "language"] + model = CaptionFile + fields = ["file_id", "language", "caption_cue"] -class CaptionViewSet(ModelViewSet): - queryset = Caption.objects.all() +class CaptionViewSet(ValuesViewset): + # Handles operations for the CaptionFile model. + queryset = CaptionFile.objects.prefetch_related("caption_cue") + permission_classes = [IsAuthenticated] serializer_class = CaptionSerializer + values = ("file_id", "language", "caption_cue") + + field_map = {"file": "file_id", "language": "language"} + + def delete_from_changes(self, changes): + errors = [] + queryset = self.get_edit_queryset().order_by() + for change in changes: + try: + instance = queryset.filter(**dict(self.values_from_key(change["key"]))) + + self.perform_destroy(instance) + except ObjectDoesNotExist: + # If the object already doesn't exist, as far as the user is concerned + # job done! + pass + except Exception as e: + log_sync_exception(e, user=self.request.user, change=change) + change["errors"] = [str(e)] + errors.append(change) + return errors + + +class CaptionCueViewSet(ValuesViewset): + # Handles operations for the CaptionCue model. + queryset = CaptionCue.objects.all() + permission_classes = [IsAuthenticated] + serializer_class = CueSerializer + values = ("text", "starttime", "endtime") - def create(self, request): - serializer = self.get_serializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - self.perform_create(serializer=serializer) - headers = self.get_success_headers(serializer.data) - return Response( - serializer.data, headers=headers, status=status.HTTP_201_CREATED - ) - - def update(self, request, pk=None): - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=True) - if not serializer.is_valid(raise_exception=True): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) + field_map = { + "text": "text", + "start_time": "starttime", + "end_time": "endtime", + } + # Add caption file in field_map? diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index 879c67e123..7606853bcc 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,7 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet -from contentcuration.viewsets.caption import CaptionViewSet +from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet @@ -15,7 +15,8 @@ from contentcuration.viewsets.invitation import InvitationViewSet from contentcuration.viewsets.sync.constants import ASSESSMENTITEM from contentcuration.viewsets.sync.constants import BOOKMARK -from contentcuration.viewsets.sync.constants import CAPTION +from contentcuration.viewsets.sync.constants import CAPTION_CUES +from contentcuration.viewsets.sync.constants import CAPTION_FILE from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import CHANNELSET from contentcuration.viewsets.sync.constants import CLIPBOARD @@ -75,7 +76,8 @@ def __init__(self, change_type, viewset_class): (EDITOR_M2M, ChannelUserViewSet), (VIEWER_M2M, ChannelUserViewSet), (SAVEDSEARCH, SavedSearchViewSet), - (CAPTION, CaptionViewSet), + (CAPTION_FILE, CaptionViewSet), + (CAPTION_CUES, CaptionCueViewSet), ] ) diff --git a/contentcuration/contentcuration/viewsets/sync/constants.py b/contentcuration/contentcuration/viewsets/sync/constants.py index 1576953b50..6ad7305c6c 100644 --- a/contentcuration/contentcuration/viewsets/sync/constants.py +++ b/contentcuration/contentcuration/viewsets/sync/constants.py @@ -22,7 +22,8 @@ # Client-side table constants BOOKMARK = "bookmark" -CAPTION = "caption" +CAPTION_FILE = "caption_file" +CAPTION_CUES = "caption_cues" CHANNEL = "channel" CONTENTNODE = "contentnode" CONTENTNODE_PREREQUISITE = "contentnode_prerequisite" @@ -40,7 +41,8 @@ ALL_TABLES = set( [ BOOKMARK, - CAPTION, + CAPTION_FILE, + CAPTION_CUES, CHANNEL, CLIPBOARD, CONTENTNODE, From a75ff84d3834f95510b0a2c39b29b096ec208000 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:33:46 +0530 Subject: [PATCH 073/257] Removes unnecessary imports --- contentcuration/contentcuration/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 71dcc0e12b..0b427ff669 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,7 +2,6 @@ import json import logging import os -from typing import Any import urllib.parse import uuid from datetime import datetime From ab08b66aa5f1d93dea706e14b3ce85e827a754be Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:35:36 +0530 Subject: [PATCH 074/257] Fixes text formatting --- .../tests/viewsets/test_caption.py | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 07ffe9dfa0..4e6eb45132 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -15,8 +15,8 @@ # class CRUDTestCase(StudioAPITestCase): -class SyncTestCase(SyncTestMixin, StudioAPITestCase): +class SyncTestCase(SyncTestMixin, StudioAPITestCase): @property def caption_file_metadata(self): return { @@ -35,7 +35,7 @@ def same_file_different_language_metadata(self): { "file_id": id, "language": "ru", - } + }, ] @property @@ -45,18 +45,16 @@ def caption_file_db_metadata(self): "language": "en", } - def setUp(self): super(SyncTestCase, self).setUp() self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata - + response = self.sync_changes( [ generate_create_event( @@ -88,10 +86,7 @@ def test_delete_caption_file(self): response = self.sync_changes( [ generate_create_event( - pk, - CAPTION_FILE, - caption_file, - channel_id=self.channel.id + pk, CAPTION_FILE, caption_file, channel_id=self.channel.id ) ] ) @@ -99,34 +94,22 @@ def test_delete_caption_file(self): # Delete the caption file response = self.sync_changes( - [ - generate_delete_event( - pk, - CAPTION_FILE, - channel_id=self.channel.id - ) - ] + [generate_delete_event(pk, CAPTION_FILE, channel_id=self.channel.id)] ) self.assertEqual(response.status_code, 200, response.content) with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file['file_id'], - language=caption_file['language'] + file_id=caption_file["file_id"], language=caption_file["language"] ) - def test_delete_file_with_same_file_id_different_language(self): self.client.force_authenticate(user=self.user) obj = self.same_file_different_language_metadata - caption_file_1 = CaptionFile.objects.create( - **obj[0] - ) - caption_file_2 = CaptionFile.objects.create( - **obj[1] - ) + caption_file_1 = CaptionFile.objects.create(**obj[0]) + caption_file_2 = CaptionFile.objects.create(**obj[1]) response = self.sync_changes( [ @@ -142,6 +125,5 @@ def test_delete_file_with_same_file_id_different_language(self): with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file_2.file_id, - language=caption_file_2.language + file_id=caption_file_2.file_id, language=caption_file_2.language ) From 8bebd4804164c042fc9e1bf76e67c977e2406455 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:11:16 +0530 Subject: [PATCH 075/257] Creating CaptionCue with generate_create_event fails --- .../CaptionsEditor/CaptionsEditor.vue | 2 +- .../tests/viewsets/test_caption.py | 115 +++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 33f25de838..531dc7d90f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -16,4 +16,4 @@ \ No newline at end of file + diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 4e6eb45132..efd2bddc42 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -2,16 +2,17 @@ import uuid -from django.urls import reverse +import json from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.viewsets.caption import CaptionFileSerializer from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests import testdata from contentcuration.tests.viewsets.base import SyncTestMixin from contentcuration.tests.viewsets.base import generate_create_event from contentcuration.tests.viewsets.base import generate_update_event from contentcuration.tests.viewsets.base import generate_delete_event -from contentcuration.viewsets.sync.constants import CAPTION_FILE +from contentcuration.viewsets.sync.constants import CAPTION_FILE, CAPTION_CUES # class CRUDTestCase(StudioAPITestCase): @@ -45,12 +46,26 @@ def caption_file_db_metadata(self): "language": "en", } + @property + def caption_cue_metadata(self): + return { + "file": { + "file_id": uuid.uuid4().hex, + "language": "en", + }, + "cue": { + "text": "This is the beginning!", + "starttime": 0.0, + "endtime": 12.0, + }, + } + def setUp(self): super(SyncTestCase, self).setUp() self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - + # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata @@ -127,3 +142,97 @@ def test_delete_file_with_same_file_id_different_language(self): caption_file_db = CaptionFile.objects.get( file_id=caption_file_2.file_id, language=caption_file_2.language ) + + def test_caption_file_serialization(self): + metadata = self.caption_file_metadata + caption_file = CaptionFile.objects.create(**metadata) + serializer = CaptionFileSerializer(instance=caption_file) + try: + jd = json.dumps(serializer.data) # Try to serialize the data to JSON + except Exception as e: + self.fail(f"CaptionFile serialization failed. Error: {str(e)}") + + def test_caption_cue_serialization(self): + metadata = self.caption_cue_metadata + caption_file = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file, + }) + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + caption_cue_2 = CaptionCue.objects.create( + text='How are you?', + starttime=2.0, + endtime=3.0, + caption_file=caption_file + ) + serializer = CaptionFileSerializer(instance=caption_file) + try: + json.dumps(serializer.data) # Try to serialize the data to JSON + except Exception as e: + self.fail(f"CaptionFile serialization failed. Error: {str(e)}") + + def test_create_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + caption_file_1 = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file_1, + }) + # This works: caption_cue_1 = CaptionCue.objects.create(**caption_cue) + response = self.sync_changes( + [ + generate_create_event( + uuid.uuid4(), + CAPTION_CUES, + caption_cue, + channel_id=self.channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + def test_delete_caption_cue(self): + self.client.force_authenticate(user=self.user) + metadata = self.caption_cue_metadata + caption_file_1 = CaptionFile.objects.create(**metadata['file']) + caption_cue = metadata['cue'] + caption_cue.update({ + "caption_file": caption_file_1, + }) + caption_cue_1 = CaptionCue.objects.create(**caption_cue) + try: + caption_cue_db = CaptionCue.objects.get( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ) + except CaptionCue.DoesNotExist: + self.fail("Caption cue not found!") + + # Delete the caption Cue that we just created + response = self.sync_changes( + [generate_delete_event(caption_cue_db.pk , CAPTION_CUES, channel_id=self.channel.id)] + ) + self.assertEqual(response.status_code, 200, response.content) + + caption_cue_db_exists = CaptionCue.objects.filter( + text=caption_cue['text'], + starttime=caption_cue['starttime'], + endtime=caption_cue['endtime'] + ).exists() + if caption_cue_db_exists: + self.fail("Caption Cue still exists!") + + def test_update_caption_cue(self): + pass From 59cbd57005cd7fb8c893bbdb6f4082cd786bd6c4 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:46:25 +0530 Subject: [PATCH 076/257] Add failing test for CaptionFile JSON serialization --- contentcuration/contentcuration/urls.py | 2 +- .../contentcuration/viewsets/caption.py | 37 ++++++------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 581505918f..763db0f696 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -56,7 +56,7 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) router.register(r'bookmark', BookmarkViewSet, basename="bookmark") -router.register(r'caption', CaptionViewSet) +router.register(r'captions', CaptionViewSet, basename="captions") router.register(r'channel', ChannelViewSet) router.register(r'channelset', ChannelSetViewSet) router.register(r'catalog', CatalogViewSet, basename='catalog') diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 1a62b191d5..55eb822976 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,35 +1,19 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.http import JsonResponse from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from contentcuration.models import CaptionCue -from contentcuration.models import CaptionFile +from contentcuration.models import CaptionCue, CaptionFile from contentcuration.viewsets.base import ValuesViewset - from contentcuration.viewsets.sync.utils import log_sync_exception -from django.core.exceptions import ObjectDoesNotExist - - -""" -[x] create file - POST /api/caption?file_id=..&language=.. -[x] delete file - DELETE /api/caption?file_id=..&language=.. - -[] create file cue - POST /api/caption/cue?file_id=..&language=.. -[] update file cue - PATCH /api/caption/cue?file_id=..&language=..&cue_id=.. -[] delete file cue - DELETE /api/caption/cue?file_id=..&language=..&cue_id=.. - -[] get the file cues - GET /api/caption?file_id=..&language=.. -""" - - -class CueSerializer(serializers.ModelSerializer): +class CaptionCueSerializer(serializers.ModelSerializer): class Meta: model = CaptionCue fields = ["text", "starttime", "endtime"] - -class CaptionSerializer(serializers.ModelSerializer): - caption_cue = CueSerializer(many=True, required=False) +class CaptionFileSerializer(serializers.ModelSerializer): + caption_cue = CaptionCueSerializer(many=True, required=False) class Meta: model = CaptionFile @@ -40,10 +24,13 @@ class CaptionViewSet(ValuesViewset): # Handles operations for the CaptionFile model. queryset = CaptionFile.objects.prefetch_related("caption_cue") permission_classes = [IsAuthenticated] - serializer_class = CaptionSerializer + serializer_class = CaptionFileSerializer values = ("file_id", "language", "caption_cue") - field_map = {"file": "file_id", "language": "language"} + field_map = { + "file": "file_id", + "language": "language" + } def delete_from_changes(self, changes): errors = [] @@ -68,7 +55,7 @@ class CaptionCueViewSet(ValuesViewset): # Handles operations for the CaptionCue model. queryset = CaptionCue.objects.all() permission_classes = [IsAuthenticated] - serializer_class = CueSerializer + serializer_class = CaptionCueSerializer values = ("text", "starttime", "endtime") field_map = { From 6c08e8ec00e4cda1b7907e825e5cce2b1c5d29ee Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 7 Jul 2023 13:12:04 +0530 Subject: [PATCH 077/257] Adds caption editor components, updated IndexedDB Resource --- .../CaptionsEditor/CaptionsEditor.vue | 15 +- .../frontend/channelEdit/constants.js | 2 +- .../channelEdit/vuex/caption/actions.js | 12 ++ .../channelEdit/vuex/caption/getters.js | 11 ++ .../channelEdit/vuex/caption/index.js | 26 +++ .../channelEdit/vuex/caption/mutations.js | 12 ++ .../frontend/shared/data/constants.js | 3 +- .../frontend/shared/data/resources.js | 17 +- contentcuration/contentcuration/models.py | 3 +- .../tests/viewsets/test_caption.py | 168 +++++++++++++----- contentcuration/contentcuration/urls.py | 3 +- .../contentcuration/viewsets/caption.py | 47 ++++- 12 files changed, 252 insertions(+), 67 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js create mode 100644 contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 531dc7d90f..4efd617226 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -1,17 +1,24 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue new file mode 100644 index 0000000000..ef7322e50d --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 4cbf88eadd..91401ed102 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -25,7 +25,6 @@ import { mapGetters, mapActions } from 'vuex'; import EditListItem from './EditListItem'; import Checkbox from 'shared/views/form/Checkbox'; - import { loadCaption } from '../../utils'; export default { name: 'EditList', @@ -44,16 +43,15 @@ }, }, watch: { - // watch the selected contentnode + // watch the selected contentnode to load captions value(newValue) { - if (this.isAIFeatureEnabled && newValue.length === 1) { - const nodeId = newValue[0]; - loadCaption([nodeId], this.loadCaptionFiles, this.loadCaptionCues); + if (newValue.length === 1) { + const nodeId = newValue[0]; // we dispatch action only when 1 node is selected + this.loadCaptions({ contentnode_id: nodeId }) } } }, computed: { - ...mapGetters('currentChannel', ['isAIFeatureEnabled']), selected: { get() { return this.value; @@ -77,7 +75,7 @@ }, }, methods: { - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), handleRemoved(nodeId) { const nodeIds = this.$route.params.detailNodeIds.split(',').filter(id => id !== nodeId); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 489b58e899..976c9e221d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -197,7 +197,6 @@ import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; import { DELAYED_VALIDATION } from 'shared/constants'; - import { loadCaption } from '../../utils'; const CHECK_STORAGE_INTERVAL = 10000; @@ -251,7 +250,7 @@ computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), - ...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'isAIFeatureEnabled']), + ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), ...mapGetters('file', ['contentNodesAreUploading', 'getContentNodeFiles']), ...mapState({ online: state => state.connection.online, @@ -307,11 +306,6 @@ return this.nodeIds.filter(id => !this.getContentNodeIsValid(id)); }, }, - created() { - if (this.isAIFeatureEnabled && this.nodeIds.length === 1) { - loadCaption(this.nodeIds, this.loadCaptionFiles, this.loadCaptionCues); - } - }, beforeRouteEnter(to, from, next) { if ( to.name === RouteNames.CONTENTNODE_DETAILS || @@ -347,6 +341,11 @@ vm.loadFiles({ contentnode__in: childrenNodesIds }), vm.loadAssessmentItems({ contentnode__in: childrenNodesIds }), ]; + if(childrenNodesIds.length === 1) { + promises.push( + vm.loadCaptions({ contentnode_id: childrenNodesIds }) + ); + } } else { // no need to load assessment items or files as topics have none promises = [vm.loadContentNode(parentTopicId)]; @@ -400,7 +399,7 @@ ]), ...mapActions('file', ['loadFiles', 'updateFile']), ...mapActions('assessmentItem', ['loadAssessmentItems', 'updateAssessmentItems']), - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), ...mapMutations('contentNode', { enableValidation: 'ENABLE_VALIDATION_ON_NODES' }), closeModal() { this.promptUploading = false; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue index 1b8d688fd4..8e70750629 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditView.vue @@ -103,7 +103,7 @@ - + @@ -120,7 +120,7 @@ import { TabNames } from '../../constants'; import AssessmentTab from '../../components/AssessmentTab/AssessmentTab'; - import CaptionsEditor from '../../components/CaptionsEditor/CaptionsEditor' + import CaptionsTab from '../../components/CaptionsTab/CaptionsTab' import RelatedResourcesTab from '../../components/RelatedResourcesTab/RelatedResourcesTab'; import DetailsTabView from './DetailsTabView'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -131,7 +131,7 @@ name: 'EditView', components: { AssessmentTab, - CaptionsEditor, + CaptionsTab, DetailsTabView, RelatedResourcesTab, Tabs, diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 3ab58f3a6b..97dd9d4713 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,4 +1,3 @@ -import store from './store'; import translator from './translator'; import { RouteNames } from './constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -390,28 +389,3 @@ export function getCompletionCriteriaLabels(node = {}, files = []) { return labels; } - -/** - * Loads caption files and cues for a content node. - * - * Caption files and cues are only loaded when: - * - The AI feature flag is enabled - * - A single node is being edited - * - The node kind is 'video' or 'audio' - * - * @param {Array} nodeIds - Array containing node ID(s) being edited - * @param {Function} loadCaptionFiles - Vuex Action to load caption files - * @param {Function} loadCaptionCues - Vuex Action to load caption cues - */ -export function loadCaption(nodeIds, loadCaptionFiles, loadCaptionCues) { - - const CONTENTNODEID = nodeIds[0]; - const KIND = store.getters["contentNode/getContentNode"](CONTENTNODEID).kind; - - if(KIND === 'video' || KIND === 'audio') { - loadCaptionFiles({ contentnode_id: CONTENTNODEID }).then(captionFile => { - let captionFileID = captionFile[0].id; // FK to retrieve the cues of a caption file - loadCaptionCues({ caption_file_id: captionFileID }) - }) - } -} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index 5db52560cd..2e62788f3f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -1,8 +1,8 @@ import { CaptionFile, CaptionCues } from 'shared/data/resources'; -export async function loadCaptionFiles({ commit }, params) { +export async function loadCaptionFiles(commit, params) { const captionFiles = await CaptionFile.where(params); - commit('ADD_CAPTIONFILES', captionFiles); + commit('ADD_CAPTIONFILES', { captionFiles, nodeId: params.contentnode_id}); return captionFiles; } @@ -11,3 +11,21 @@ export async function loadCaptionCues({ commit }, { caption_file_id }) { commit('ADD_CAPTIONCUES', cues); return cues; } + +export async function loadCaptions({ commit, rootGetters }, params) { + const AI_FEATURE_FLAG = rootGetters['currentChannel/isAIFeatureEnabled'] + if(!AI_FEATURE_FLAG) return; + + const FILE_TYPE = rootGetters['contentNode/getContentNode'](params.contentnode_id).kind; + if(FILE_TYPE === 'video' || FILE_TYPE === 'audio') { + const captionFiles = await loadCaptionFiles(commit, params); + // If there is no Caption File for this contentnode + // Don't request for the cues + if(captionFiles.length === 0) return; + // TODO: call loadCaptionCues -> to be done after I finish saving captionFiles in indexedDB When CTA is called. So I have captions saved in the backend. + } +} + +export async function addCaptionFile({ commit }, { captionFile, nodeId }) { + commit('ADD_CAPTIONFILE', { captionFile, nodeId }); +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js index d74808f3d8..f1050ea879 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/getters.js @@ -1,3 +1,3 @@ -// export function getCaptionFiles(state) { -// return Object.values(state.captionFilesMap); -// } +export function getContentNodeId(state) { + return state.currentContentNode.contentnode_id; +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js index 399bc64436..e30006eda2 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js @@ -7,9 +7,16 @@ export default { namespaced: true, state: () => ({ /* List of caption files for a contentnode - * to be defined + * [ + * contentnode_id: { + * pk: { + * file_id: file_id + * language: language + * } + * }, + * ] */ - captionFilesMap: {}, + captionFilesMap: [], /* Caption Cues json to render in the frontend caption-editor * to be defined */ @@ -20,7 +27,7 @@ export default { actions, listeners: { [TABLE_NAMES.CAPTION_FILE]: { - [CHANGE_TYPES.CREATED]: 'ADD_CAPTIONFILES', + [CHANGE_TYPES.CREATED]: 'ADD_CAPTIONFILE', [CHANGE_TYPES.UPDATED]: 'UPDATE_CAPTIONFILE_FROM_INDEXEDDB', [CHANGE_TYPES.DELETED]: 'DELETE_CAPTIONFILE', }, diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js index 78970da8a0..dd0b071d24 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js @@ -1,16 +1,34 @@ import Vue from "vue"; /* Mutations for Caption File */ -export function ADD_CAPTIONFILE(state, captionFile) { - // TODO: add some checks to File +export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { + if(!state.captionFilesMap[nodeId]) { + Vue.set(state.captionFilesMap, nodeId, {}); + } + + // Check if the pk exists in the contentNode's object + if (!state.captionFilesMap[nodeId][captionFile.id]) { + // If it doesn't exist, create an empty object for that pk + Vue.set(state.captionFilesMap[nodeId], captionFile.id, {}); + } + + // Check if the file_id and language combination already exists + const key = `${captionFile.file_id}_${captionFile.language}`; + // if(state.captionFilesMap[nodeId][captionFile.id]) { + + // } - Vue.set(state.captionFilesMap, captionFile.id, captionFile); + // Finally, set the file_id and language for that pk + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'id', captionFile.id); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'file_id', captionFile.file_id); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'language', captionFile.language); + console.log(state.captionFilesMap); } -export function ADD_CAPTIONFILES(state, captionFiles = []) { - if (Array.isArray(captionFiles)) { // Workaround to fix TypeError: captionFiles.forEach +export function ADD_CAPTIONFILES(state, captionFiles, nodeId) { + if (Array.isArray(captionFiles)) { captionFiles.forEach(captionFile => { - ADD_CAPTIONFILE(state, captionFile); + ADD_CAPTIONFILE(state, captionFile, nodeId); }); } } From 52f7a20e0eb2c00589d59b652c742f6c4913779d Mon Sep 17 00:00:00 2001 From: akash5100 Date: Thu, 27 Jul 2023 22:46:37 +0530 Subject: [PATCH 084/257] Stage changes before rebase --- .../components/CaptionsTab/CaptionsTab.vue | 31 ++---- .../components/CaptionsTab/languages.js | 101 ++++++++++++++++++ .../channelEdit/components/edit/EditList.vue | 11 -- .../channelEdit/components/edit/EditModal.vue | 6 +- .../channelEdit/vuex/caption/actions.js | 54 +++++++--- .../channelEdit/vuex/caption/mutations.js | 17 ++- .../contentcuration/viewsets/caption.py | 6 +- 7 files changed, 164 insertions(+), 62 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue index ef7322e50d..eaa2346916 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue @@ -20,7 +20,7 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index 5c9c2d6a21..53d5223333 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -37,7 +37,9 @@ export async function loadCaptions({ commit, rootGetters }, params) { // If there is no Caption File for this contentnode // Don't request for the cues if(captionFiles.length === 0) return; - // TODO: call loadCaptionCues -> to be done after I finish saving captionFiles in indexedDB When CTA is called. So I have captions saved in the backend. + // TODO: call loadCaptionCues -> to be done after + // I finish saving captionFiles in indexedDB When + // CTA is called. So I have captions saved in the backend. } @@ -46,7 +48,7 @@ export async function addCaptionFile({ commit }, { file_id, language, nodeId }) file_id: file_id, language: language } - return CaptionFile.put(captionFile).then(id => { + return CaptionFile.add(captionFile).then(id => { captionFile.id = id; console.log(captionFile, nodeId); commit('ADD_CAPTIONFILE', { diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 88536368b2..39cf44f17f 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1023,6 +1023,7 @@ export const CaptionFile = new Resource({ idField: 'id', indexFields: ['file_id', 'language'], syncable: true, + getChannelId: getChannelFromChannelScope, }); export const CaptionCues = new Resource({ @@ -1031,6 +1032,7 @@ export const CaptionCues = new Resource({ idField: 'id', indexFields: ['text', 'starttime', 'endtime'], syncable: true, + getChannelId: getChannelFromChannelScope, collectionUrl(caption_file_id) { return this.getUrlFunction('list')(caption_file_id) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js b/contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js similarity index 73% rename from contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js rename to contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js index 703d856715..c7bf2a4f1e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js +++ b/contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js @@ -1,4 +1,13 @@ -export const CAPTIONS_LANGUAGES = { +/** + * This file generates the list of supported caption languages by + * filtering the full list of languages against the whisperLanguages object. + * To switch to a new model for supported languages, you can update the + * whisperLanguages object with new language codes and names. +*/ + +import { LanguagesList } from 'shared/leUtils/Languages'; + +const whisperLanguages = { en: "english", zh: "chinese", de: "german", @@ -98,4 +107,14 @@ export const CAPTIONS_LANGUAGES = { ba: "bashkir", jw: "javanese", su: "sundanese", -} \ No newline at end of file +} + +export const supportedCaptionLanguages = LanguagesList.filter( + (language) => whisperLanguages.hasOwnProperty(language.lang_code) +); + +export const notSupportedCaptionLanguages = LanguagesList.filter( + (language) => !whisperLanguages.hasOwnProperty(language.lang_code) +); + +export default supportedCaptionLanguages; diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py deleted file mode 100644 index b8176d6a02..0000000000 --- a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-26 17:33 - -import contentcuration.models -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='CaptionFile', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), - ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), - ], - options={ - 'unique_together': {('file_id', 'language')}, - }, - ), - migrations.CreateModel( - name='CaptionCue', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('text', models.TextField()), - ('starttime', models.FloatField()), - ('endtime', models.FloatField()), - ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0145_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0145_captioncue_captionfile.py new file mode 100644 index 0000000000..29d5bffb97 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0145_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-08-01 06:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0144_soft_delete_user'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_file', to='contentcuration.language')), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 25896bb34b..c4f55d1eb3 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2070,13 +2070,19 @@ class CaptionFile(models.Model): """ id = UUIDField(primary_key=True, default=uuid.uuid4) file_id = UUIDField(default=uuid.uuid4, max_length=36) - language = models.CharField(choices=CAPTIONS_LANGUAGES, max_length=3) + language = models.ForeignKey(Language, related_name="caption_file", on_delete=models.CASCADE) class Meta: unique_together = ['file_id', 'language'] def __str__(self): return "file_id: {file_id}, language: {language}".format(file_id=self.file_id, language=self.language) + + def save(self, *args, **kwargs): + # Check if the language is supported by speech-to-text AI model. + if self.language and self.language.lang_code not in CAPTIONS_LANGUAGES: + raise ValueError(f"The language is currently not supported by speech-to-text model.") + super(CaptionFile, self).save(*args, **kwargs) class CaptionCue(models.Model): diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 6931d5bdbb..e4579b83a9 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -3,7 +3,7 @@ import json import uuid -from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.models import CaptionCue, CaptionFile, Language from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import ( @@ -21,7 +21,7 @@ class SyncTestCase(SyncTestMixin, StudioAPITestCase): def caption_file_metadata(self): return { "file_id": uuid.uuid4().hex, - "language": "en", + "language": Language.objects.get(pk="en").pk, } @property @@ -30,27 +30,20 @@ def same_file_different_language_metadata(self): return [ { "file_id": id, - "language": "en", + "language": Language.objects.get(pk="en"), }, { "file_id": id, - "language": "ru", + "language": Language.objects.get(pk="ru"), }, ] - @property - def caption_file_db_metadata(self): - return { - "file_id": uuid.uuid4().hex, - "language": "en", - } - @property def caption_cue_metadata(self): return { "file": { "file_id": uuid.uuid4().hex, - "language": "en", + "language": Language.objects.get(pk="en").pk, }, "cue": { "text": "This is the beginning!", @@ -65,7 +58,6 @@ def setUp(self): self.user = testdata.user() self.channel.editors.add(self.user) - # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata @@ -85,18 +77,20 @@ def test_create_caption(self): try: caption_file_db = CaptionFile.objects.get( file_id=caption_file["file_id"], - language=caption_file["language"], + language_id=caption_file["language"], ) except CaptionFile.DoesNotExist: self.fail("caption file was not created") # Check the values of the object in the PostgreSQL self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) - self.assertEqual(caption_file_db.language, caption_file["language"]) + self.assertEqual(caption_file_db.language_id, caption_file["language"]) def test_delete_caption_file(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata + # Explicitly set language to model object to follow Django ORM conventions + caption_file['language'] = Language.objects.get(pk='en') caption_file_1 = CaptionFile(**caption_file) pk = caption_file_1.pk @@ -108,7 +102,7 @@ def test_delete_caption_file(self): with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file["file_id"], language=caption_file["language"] + file_id=caption_file["file_id"], language_id=caption_file["language"] ) def test_delete_file_with_same_file_id_different_language(self): @@ -132,11 +126,13 @@ def test_delete_file_with_same_file_id_different_language(self): with self.assertRaises(CaptionFile.DoesNotExist): caption_file_db = CaptionFile.objects.get( - file_id=caption_file_2.file_id, language=caption_file_2.language + file_id=caption_file_2.file_id, language_id=caption_file_2.language ) def test_caption_file_serialization(self): metadata = self.caption_file_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['language'] = Language.objects.get(pk="en") caption_file = CaptionFile.objects.create(**metadata) serializer = CaptionFileSerializer(instance=caption_file) try: @@ -146,6 +142,8 @@ def test_caption_file_serialization(self): def test_caption_cue_serialization(self): metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update( @@ -166,6 +164,8 @@ def test_caption_cue_serialization(self): def test_create_caption_cue(self): self.client.force_authenticate(user=self.user) metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file_1 = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue["caption_file_id"] = caption_file_1.pk @@ -194,6 +194,8 @@ def test_create_caption_cue(self): def test_delete_caption_cue(self): self.client.force_authenticate(user=self.user) metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file_1 = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update({"caption_file": caption_file_1}) @@ -228,6 +230,8 @@ def test_delete_caption_cue(self): def test_update_caption_cue(self): self.client.force_authenticate(user=self.user) metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file_1 = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] @@ -280,6 +284,8 @@ def test_update_caption_cue(self): def test_invalid_caption_cue_data_serialization(self): metadata = self.caption_cue_metadata + # Explicitly set language to model object to follow Django ORM conventions + metadata['file']['language'] = Language.objects.get(pk="en") caption_file = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update( diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 09550a7655..419605f0a5 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -2,7 +2,6 @@ AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES, - VIDEO_SUBTITLE, ) from rest_framework import serializers from rest_framework.permissions import IsAuthenticated @@ -26,6 +25,13 @@ def validate(self, attrs): return attrs def to_internal_value(self, data): + """ + Copies the caption_file_id from the request data + to the internal representation before validation. + + Without this, the caption_file_id would be lost + if validation fails, leading to errors. + """ caption_file_id = data.get("caption_file_id") value = super().to_internal_value(data) @@ -43,6 +49,12 @@ class Meta: @classmethod def id_attr(cls): + """ + Returns the primary key name for the model class. + + Checks Meta.update_lookup_field to allow customizable + primary key names. Falls back to using the default "id". + """ ModelClass = cls.Meta.model info = model_meta.get_field_info(ModelClass) return getattr(cls.Meta, "update_lookup_field", info.pk.name) @@ -63,13 +75,14 @@ class CaptionViewSet(ValuesViewset): def get_queryset(self): queryset = super().get_queryset() - contentnode_ids = self.request.GET.get("contentnode_id").split(',') + contentnode_ids = self.request.GET.get("contentnode_id") file_id = self.request.GET.get("file_id") language = self.request.GET.get("language") if contentnode_ids: + contentnode_ids = contentnode_ids.split(',') file_ids = File.objects.filter( - preset_id__in=[AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES, VIDEO_SUBTITLE], + preset_id__in=[AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES], contentnode_id__in=contentnode_ids, ).values_list("pk", flat=True) queryset = queryset.filter(file_id__in=file_ids) From 1ca1aa7496d3ee26a4d6592667b5e1f91cc622b5 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 23 May 2023 16:51:29 +0530 Subject: [PATCH 086/257] created captionviewset --- contentcuration/contentcuration/urls.py | 1 + .../contentcuration/viewsets/captions.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 contentcuration/contentcuration/viewsets/captions.py diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 692c8cc938..52b74bbf60 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,6 +32,7 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet +from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import AdminChannelViewSet diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py new file mode 100644 index 0000000000..148a187698 --- /dev/null +++ b/contentcuration/contentcuration/viewsets/captions.py @@ -0,0 +1,27 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +# from contentcuration.models import GeneratedCaptions + + +class GeneratedCaptionsSerializer(serializers.ModelSerializer): + class Meta: + # model = GeneratedCaptions + fields = ['id', 'generated_captions', 'language'] + +class CaptionViewSet(ModelViewSet): + # queryset = GeneratedCaptions.objects.all() + serializer_class = GeneratedCaptionsSerializer + + def create(self, request): + # handles the creation operation and return serialized data + pass + + def update(self, request): + # handles the updating of an existing `GeneratedCaption` instance. + pass + + def destroy(self, request): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file From e7d2139624d53927f957b4831fa3b31d9a2a0430 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Mon, 19 Jun 2023 12:40:39 +0530 Subject: [PATCH 087/257] Adds captions modal with visibility controlled by featureflag --- .../CaptionsEditor/CaptionsEditor.vue | 19 +++++++++++++++ .../frontend/shared/data/resources.js | 4 ++++ .../migrations/0143_caption.py | 23 +++++++++++++++++++ contentcuration/contentcuration/urls.py | 1 - 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue create mode 100644 contentcuration/contentcuration/migrations/0143_caption.py diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue new file mode 100644 index 0000000000..33f25de838 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 39cf44f17f..748d6ae298 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1076,6 +1076,10 @@ export const CaptionCues = new Resource({ } }); +export const Caption = new Resource({ + // TODO +}) + export const Channel = new Resource({ tableName: TABLE_NAMES.CHANNEL, urlName: 'channel', diff --git a/contentcuration/contentcuration/migrations/0143_caption.py b/contentcuration/contentcuration/migrations/0143_caption.py new file mode 100644 index 0000000000..3d6e0e769c --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_caption.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.14 on 2023-06-15 06:13 + +import contentcuration.models +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='Caption', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('caption', models.JSONField()), + ('language', models.CharField(max_length=10)), + ], + ), + ] diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 52b74bbf60..692c8cc938 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,7 +32,6 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet -from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import AdminChannelViewSet From 4467629afa645ea5141c843de48c4ece97729a56 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:29:13 +0530 Subject: [PATCH 088/257] Adds Sync API tests for CaptionFile ViewSet --- .../constants/transcription_languages.py | 236 ++++++++---------- .../migrations/0143_caption.py | 23 -- .../migrations/0143_captioncue_captionfile.py | 37 +++ contentcuration/contentcuration/models.py | 1 + 4 files changed, 139 insertions(+), 158 deletions(-) delete mode 100644 contentcuration/contentcuration/migrations/0143_caption.py create mode 100644 contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py diff --git a/contentcuration/contentcuration/constants/transcription_languages.py b/contentcuration/contentcuration/constants/transcription_languages.py index 7eeefca57c..c442e04813 100644 --- a/contentcuration/contentcuration/constants/transcription_languages.py +++ b/contentcuration/contentcuration/constants/transcription_languages.py @@ -1,136 +1,102 @@ -# This file provides a list of transcription languages supported by OpenAI/Whisper. -# The list of languages is obtained from the Whisper project on GitHub, specifically from the tokenizer module. -# You can find the complete list of available languages in the tokenizer module: -# https://github.com/openai/whisper/blob/main/whisper/tokenizer.py - -# The supported languages are stored in the 'LANGUAGES' dictionary in the format of language code and language name. -# For example, the first element in the 'LANGUAGES' dictionary is ('en', 'english'). - -# To add support for a new model, we also need to update the `supportedLanguageList` array in the frontend TranscriptionLanguages.js file. -# https://github.com/learningequality/studio/blob/unstable/contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js - -import json -import le_utils.resources as resources - -WHISPER_LANGUAGES = { - "en": "english", - "zh": "chinese", - "de": "german", - "es": "spanish", - "ru": "russian", - "ko": "korean", - "fr": "french", - "ja": "japanese", - "pt": "portuguese", - "tr": "turkish", - "pl": "polish", - "ca": "catalan", - "nl": "dutch", - "ar": "arabic", - "sv": "swedish", - "it": "italian", - "id": "indonesian", - "hi": "hindi", - "fi": "finnish", - "vi": "vietnamese", - "he": "hebrew", - "uk": "ukrainian", - "el": "greek", - "ms": "malay", - "cs": "czech", - "ro": "romanian", - "da": "danish", - "hu": "hungarian", - "ta": "tamil", - "no": "norwegian", - "th": "thai", - "ur": "urdu", - "hr": "croatian", - "bg": "bulgarian", - "lt": "lithuanian", - "la": "latin", - "mi": "maori", - "ml": "malayalam", - "cy": "welsh", - "sk": "slovak", - "te": "telugu", - "fa": "persian", - "lv": "latvian", - "bn": "bengali", - "sr": "serbian", - "az": "azerbaijani", - "sl": "slovenian", - "kn": "kannada", - "et": "estonian", - "mk": "macedonian", - "br": "breton", - "eu": "basque", - "is": "icelandic", - "hy": "armenian", - "ne": "nepali", - "mn": "mongolian", - "bs": "bosnian", - "kk": "kazakh", - "sq": "albanian", - "sw": "swahili", - "gl": "galician", - "mr": "marathi", - "pa": "punjabi", - "si": "sinhala", - "km": "khmer", - "sn": "shona", - "yo": "yoruba", - "so": "somali", - "af": "afrikaans", - "oc": "occitan", - "ka": "georgian", - "be": "belarusian", - "tg": "tajik", - "sd": "sindhi", - "gu": "gujarati", - "am": "amharic", - "yi": "yiddish", - "lo": "lao", - "uz": "uzbek", - "fo": "faroese", - "ht": "haitian creole", - "ps": "pashto", - "tk": "turkmen", - "nn": "nynorsk", - "mt": "maltese", - "sa": "sanskrit", - "lb": "luxembourgish", - "my": "myanmar", - "bo": "tibetan", - "tl": "tagalog", - "mg": "malagasy", - "as": "assamese", - "tt": "tatar", - "haw": "hawaiian", - "ln": "lingala", - "ha": "hausa", - "ba": "bashkir", - "jw": "javanese", - "su": "sundanese", -} - -def _load_kolibri_languages(): - """Load Kolibri languages from JSON file and return the language codes as a list.""" - filepath = resources.__path__[0] - kolibri_languages = [] - with open(f'{filepath}/languagelookup.json') as f: - kolibri_languages = list(json.load(f).keys()) - return kolibri_languages - -def _load_model_languages(languages): - """Load languages supported by the speech-to-text model.""" - return list(languages.keys()) - -def create_captions_languages(): - """Create the intersection of transcription model and Kolibri languages.""" - kolibri_set = set(_load_kolibri_languages()) - model_set = set(_load_model_languages(languages=WHISPER_LANGUAGES)) - return list(kolibri_set.intersection(model_set)) - -CAPTIONS_LANGUAGES = create_captions_languages() +CAPTIONS_LANGUAGES = [ + ("en", "english"), + ("zh", "chinese"), + ("de", "german"), + ("es", "spanish"), + ("ru", "russian"), + ("ko", "korean"), + ("fr", "french"), + ("ja", "japanese"), + ("pt", "portuguese"), + ("tr", "turkish"), + ("pl", "polish"), + ("ca", "catalan"), + ("nl", "dutch"), + ("ar", "arabic"), + ("sv", "swedish"), + ("it", "italian"), + ("id", "indonesian"), + ("hi", "hindi"), + ("fi", "finnish"), + ("vi", "vietnamese"), + ("he", "hebrew"), + ("uk", "ukrainian"), + ("el", "greek"), + ("ms", "malay"), + ("cs", "czech"), + ("ro", "romanian"), + ("da", "danish"), + ("hu", "hungarian"), + ("ta", "tamil"), + ("no", "norwegian"), + ("th", "thai"), + ("ur", "urdu"), + ("hr", "croatian"), + ("bg", "bulgarian"), + ("lt", "lithuanian"), + ("la", "latin"), + ("mi", "maori"), + ("ml", "malayalam"), + ("cy", "welsh"), + ("sk", "slovak"), + ("te", "telugu"), + ("fa", "persian"), + ("lv", "latvian"), + ("bn", "bengali"), + ("sr", "serbian"), + ("az", "azerbaijani"), + ("sl", "slovenian"), + ("kn", "kannada"), + ("et", "estonian"), + ("mk", "macedonian"), + ("br", "breton"), + ("eu", "basque"), + ("is", "icelandic"), + ("hy", "armenian"), + ("ne", "nepali"), + ("mn", "mongolian"), + ("bs", "bosnian"), + ("kk", "kazakh"), + ("sq", "albanian"), + ("sw", "swahili"), + ("gl", "galician"), + ("mr", "marathi"), + ("pa", "punjabi"), + ("si", "sinhala"), + ("km", "khmer"), + ("sn", "shona"), + ("yo", "yoruba"), + ("so", "somali"), + ("af", "afrikaans"), + ("oc", "occitan"), + ("ka", "georgian"), + ("be", "belarusian"), + ("tg", "tajik"), + ("sd", "sindhi"), + ("gu", "gujarati"), + ("am", "amharic"), + ("yi", "yiddish"), + ("lo", "lao"), + ("uz", "uzbek"), + ("fo", "faroese"), + ("ht", "haitian creole"), + ("ps", "pashto"), + ("tk", "turkmen"), + ("nn", "nynorsk"), + ("mt", "maltese"), + ("sa", "sanskrit"), + ("lb", "luxembourgish"), + ("my", "myanmar"), + ("bo", "tibetan"), + ("tl", "tagalog"), + ("mg", "malagasy"), + ("as", "assamese"), + ("tt", "tatar"), + ("haw", "hawaiian"), + ("ln", "lingala"), + ("ha", "hausa"), + ("ba", "bashkir"), + ("jw", "javanese"), + ("su", "sundanese"), +] \ No newline at end of file diff --git a/contentcuration/contentcuration/migrations/0143_caption.py b/contentcuration/contentcuration/migrations/0143_caption.py deleted file mode 100644 index 3d6e0e769c..0000000000 --- a/contentcuration/contentcuration/migrations/0143_caption.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-15 06:13 - -import contentcuration.models -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='Caption', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('caption', models.JSONField()), - ('language', models.CharField(max_length=10)), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py new file mode 100644 index 0000000000..b8176d6a02 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-06-26 17:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index c4f55d1eb3..9f43d55db8 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,6 +2,7 @@ import json import logging import os +from typing import Any import urllib.parse import uuid from datetime import datetime From e5805be7341966b3bf41f28371b7cc2f85c4b1ac Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:33:46 +0530 Subject: [PATCH 089/257] Removes unnecessary imports --- contentcuration/contentcuration/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 9f43d55db8..c4f55d1eb3 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,7 +2,6 @@ import json import logging import os -from typing import Any import urllib.parse import uuid from datetime import datetime From 78df2e912df67add9ec92bea83c0f65bb399dc1e Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:11:16 +0530 Subject: [PATCH 090/257] Creating CaptionCue with generate_create_event fails --- .../channelEdit/components/CaptionsEditor/CaptionsEditor.vue | 2 +- contentcuration/contentcuration/tests/viewsets/test_caption.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 33f25de838..531dc7d90f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -16,4 +16,4 @@ \ No newline at end of file + diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index e4579b83a9..f88ff780ec 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -57,7 +57,7 @@ def setUp(self): self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - + # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata From 57c968487522e2b0682056847629350de2d7742d Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 7 Jul 2023 13:12:04 +0530 Subject: [PATCH 091/257] Adds caption editor components, updated IndexedDB Resource --- .../CaptionsEditor/CaptionsEditor.vue | 15 +++-- .../channelEdit/vuex/caption/actions.js | 2 +- .../channelEdit/vuex/caption/getters.js | 2 +- .../frontend/shared/data/resources.js | 17 +++++- .../tests/viewsets/test_caption.py | 20 +++++++ .../contentcuration/viewsets/caption.py | 55 +++++++++++++++++++ 6 files changed, 102 insertions(+), 9 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 531dc7d90f..4efd617226 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -1,17 +1,24 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 4cbf88eadd..91401ed102 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -25,7 +25,6 @@ import { mapGetters, mapActions } from 'vuex'; import EditListItem from './EditListItem'; import Checkbox from 'shared/views/form/Checkbox'; - import { loadCaption } from '../../utils'; export default { name: 'EditList', @@ -44,16 +43,15 @@ }, }, watch: { - // watch the selected contentnode + // watch the selected contentnode to load captions value(newValue) { - if (this.isAIFeatureEnabled && newValue.length === 1) { - const nodeId = newValue[0]; - loadCaption([nodeId], this.loadCaptionFiles, this.loadCaptionCues); + if (newValue.length === 1) { + const nodeId = newValue[0]; // we dispatch action only when 1 node is selected + this.loadCaptions({ contentnode_id: nodeId }) } } }, computed: { - ...mapGetters('currentChannel', ['isAIFeatureEnabled']), selected: { get() { return this.value; @@ -77,7 +75,7 @@ }, }, methods: { - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), handleRemoved(nodeId) { const nodeIds = this.$route.params.detailNodeIds.split(',').filter(id => id !== nodeId); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index c88c12a3be..c7e1ec645d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -197,7 +197,6 @@ import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; import { DELAYED_VALIDATION } from 'shared/constants'; - import { loadCaption } from '../../utils'; const CHECK_STORAGE_INTERVAL = 10000; @@ -251,7 +250,7 @@ computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), - ...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'isAIFeatureEnabled']), + ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), ...mapGetters('file', ['contentNodesAreUploading', 'getContentNodeFiles']), ...mapState({ online: state => state.connection.online, @@ -307,11 +306,6 @@ return this.nodeIds.filter(id => !this.getContentNodeIsValid(id)); }, }, - created() { - if (this.isAIFeatureEnabled && this.nodeIds.length === 1) { - loadCaption(this.nodeIds, this.loadCaptionFiles, this.loadCaptionCues); - } - }, beforeRouteEnter(to, from, next) { if ( to.name === RouteNames.CONTENTNODE_DETAILS || @@ -348,6 +342,11 @@ vm.loadAssessmentItems({ contentnode__in: childrenNodesIds }), vm.loadCaptions({ contentnode_id: childrenNodesIds }) ]; + if(childrenNodesIds.length === 1) { + promises.push( + vm.loadCaptions({ contentnode_id: childrenNodesIds }) + ); + } } else { // no need to load assessment items or files as topics have none promises = [vm.loadContentNode(parentTopicId)]; diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 3ab58f3a6b..97dd9d4713 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,4 +1,3 @@ -import store from './store'; import translator from './translator'; import { RouteNames } from './constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -390,28 +389,3 @@ export function getCompletionCriteriaLabels(node = {}, files = []) { return labels; } - -/** - * Loads caption files and cues for a content node. - * - * Caption files and cues are only loaded when: - * - The AI feature flag is enabled - * - A single node is being edited - * - The node kind is 'video' or 'audio' - * - * @param {Array} nodeIds - Array containing node ID(s) being edited - * @param {Function} loadCaptionFiles - Vuex Action to load caption files - * @param {Function} loadCaptionCues - Vuex Action to load caption cues - */ -export function loadCaption(nodeIds, loadCaptionFiles, loadCaptionCues) { - - const CONTENTNODEID = nodeIds[0]; - const KIND = store.getters["contentNode/getContentNode"](CONTENTNODEID).kind; - - if(KIND === 'video' || KIND === 'audio') { - loadCaptionFiles({ contentnode_id: CONTENTNODEID }).then(captionFile => { - let captionFileID = captionFile[0].id; // FK to retrieve the cues of a caption file - loadCaptionCues({ caption_file_id: captionFileID }) - }) - } -} From c13fbe8ed25d56e132be500e4594327882aeef1c Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 1 Aug 2023 23:41:06 +0530 Subject: [PATCH 098/257] maybe this will break the --- .../channelEdit/vuex/caption/actions.js | 45 +------------------ .../tests/viewsets/test_caption.py | 19 -------- 2 files changed, 1 insertion(+), 63 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index b6195372d8..a99608b4a8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -1,46 +1,3 @@ -import { CaptionFile, CaptionCues } from 'shared/data/resources'; - -export async function loadCaptionFiles(commit, params) { - const captionFiles = await CaptionFile.where(params); - commit('ADD_CAPTIONFILES', { captionFiles, nodeIds: params.contentnode_id }); - return captionFiles; -} - -export async function loadCaptionCues({ commit }, { caption_file_id }) { - const cues = await CaptionCues.where({ caption_file_id }) - commit('ADD_CAPTIONCUES', cues); - return cues; -} - -export async function loadCaptions({ commit, rootGetters }, params) { - const isAIFeatureEnabled = rootGetters['currentChannel/isAIFeatureEnabled']; - if(!isAIFeatureEnabled) return; - // If a new file is uploaded, the contentnode_id will be string - if(typeof params.contentnode_id === 'string') { - params.contentnode_id = [params.contentnode_id] - } - const nodeIdsToLoad = []; - for (const nodeId of params.contentnode_id) { - const node = rootGetters['contentNode/getContentNode'](nodeId); - if (node && (node.kind === 'video' || node.kind === 'audio')) { - nodeIdsToLoad.push(nodeId); // already in vuex - } else if(!node) { - nodeIdsToLoad.push(nodeId); // Assume that its audio/video - } - } - - const captionFiles = await loadCaptionFiles(commit, { - contentnode_id: nodeIdsToLoad - }); - - // If there is no Caption File for this contentnode - // Don't request for the cues - if(captionFiles.length === 0) return; - // TODO: call loadCaptionCues -> to be done after - // I finish saving captionFiles in indexedDB When - // CTA is called. So I have captions saved in the backend. -} - export async function addCaptionFile({ commit }, { file_id, language, nodeId }) { const captionFile = { @@ -55,4 +12,4 @@ export async function addCaptionFile({ commit }, { file_id, language, nodeId }) nodeId }); }) -} +} \ No newline at end of file diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 40c6c08fa0..7d2a699230 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -3,13 +3,7 @@ import json import uuid -<<<<<<< HEAD from contentcuration.models import CaptionCue, CaptionFile, Language -======= -from django.core.serializers import serialize - -from contentcuration.models import CaptionCue, CaptionFile ->>>>>>> de52608ba (Adds caption editor components, updated IndexedDB Resource) from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import ( @@ -20,11 +14,6 @@ ) from contentcuration.viewsets.caption import CaptionCueSerializer, CaptionFileSerializer from contentcuration.viewsets.sync.constants import CAPTION_CUES, CAPTION_FILE -<<<<<<< HEAD -======= - -# class CRUDTestCase(StudioAPITestCase): ->>>>>>> de52608ba (Adds caption editor components, updated IndexedDB Resource) class SyncTestCase(SyncTestMixin, StudioAPITestCase): @@ -53,13 +42,8 @@ def same_file_different_language_metadata(self): def caption_cue_metadata(self): return { "file": { -<<<<<<< HEAD "file_id": uuid.uuid4().hex, "language": Language.objects.get(pk="en").pk, -======= - "file_id": uuid.uuid4(), - "language": "en", ->>>>>>> de52608ba (Adds caption editor components, updated IndexedDB Resource) }, "cue": { "text": "This is the beginning!", @@ -301,11 +285,8 @@ def test_update_caption_cue(self): def test_invalid_caption_cue_data_serialization(self): metadata = self.caption_cue_metadata -<<<<<<< HEAD # Explicitly set language to model object to follow Django ORM conventions metadata['file']['language'] = Language.objects.get(pk="en") -======= ->>>>>>> de52608ba (Adds caption editor components, updated IndexedDB Resource) caption_file = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue.update( From fbb4a07de62d29af54d553b988a50d984c555653 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Wed, 2 Aug 2023 00:27:10 +0530 Subject: [PATCH 099/257] fixs merge conflict --- .../constants/transcription_languages.py | 234 ++++++++++-------- .../channelEdit/components/edit/EditList.vue | 11 - .../channelEdit/components/edit/EditModal.vue | 5 - .../frontend/shared/data/resources.js | 57 ----- 4 files changed, 133 insertions(+), 174 deletions(-) diff --git a/contentcuration/contentcuration/constants/transcription_languages.py b/contentcuration/contentcuration/constants/transcription_languages.py index c442e04813..e68fcaceb1 100644 --- a/contentcuration/contentcuration/constants/transcription_languages.py +++ b/contentcuration/contentcuration/constants/transcription_languages.py @@ -1,102 +1,134 @@ +# The list of languages is obtained from the Whisper project on GitHub, specifically from the tokenizer module. +# You can find the complete list of available languages in the tokenizer module: +# https://github.com/openai/whisper/blob/main/whisper/tokenizer.py -CAPTIONS_LANGUAGES = [ - ("en", "english"), - ("zh", "chinese"), - ("de", "german"), - ("es", "spanish"), - ("ru", "russian"), - ("ko", "korean"), - ("fr", "french"), - ("ja", "japanese"), - ("pt", "portuguese"), - ("tr", "turkish"), - ("pl", "polish"), - ("ca", "catalan"), - ("nl", "dutch"), - ("ar", "arabic"), - ("sv", "swedish"), - ("it", "italian"), - ("id", "indonesian"), - ("hi", "hindi"), - ("fi", "finnish"), - ("vi", "vietnamese"), - ("he", "hebrew"), - ("uk", "ukrainian"), - ("el", "greek"), - ("ms", "malay"), - ("cs", "czech"), - ("ro", "romanian"), - ("da", "danish"), - ("hu", "hungarian"), - ("ta", "tamil"), - ("no", "norwegian"), - ("th", "thai"), - ("ur", "urdu"), - ("hr", "croatian"), - ("bg", "bulgarian"), - ("lt", "lithuanian"), - ("la", "latin"), - ("mi", "maori"), - ("ml", "malayalam"), - ("cy", "welsh"), - ("sk", "slovak"), - ("te", "telugu"), - ("fa", "persian"), - ("lv", "latvian"), - ("bn", "bengali"), - ("sr", "serbian"), - ("az", "azerbaijani"), - ("sl", "slovenian"), - ("kn", "kannada"), - ("et", "estonian"), - ("mk", "macedonian"), - ("br", "breton"), - ("eu", "basque"), - ("is", "icelandic"), - ("hy", "armenian"), - ("ne", "nepali"), - ("mn", "mongolian"), - ("bs", "bosnian"), - ("kk", "kazakh"), - ("sq", "albanian"), - ("sw", "swahili"), - ("gl", "galician"), - ("mr", "marathi"), - ("pa", "punjabi"), - ("si", "sinhala"), - ("km", "khmer"), - ("sn", "shona"), - ("yo", "yoruba"), - ("so", "somali"), - ("af", "afrikaans"), - ("oc", "occitan"), - ("ka", "georgian"), - ("be", "belarusian"), - ("tg", "tajik"), - ("sd", "sindhi"), - ("gu", "gujarati"), - ("am", "amharic"), - ("yi", "yiddish"), - ("lo", "lao"), - ("uz", "uzbek"), - ("fo", "faroese"), - ("ht", "haitian creole"), - ("ps", "pashto"), - ("tk", "turkmen"), - ("nn", "nynorsk"), - ("mt", "maltese"), - ("sa", "sanskrit"), - ("lb", "luxembourgish"), - ("my", "myanmar"), - ("bo", "tibetan"), - ("tl", "tagalog"), - ("mg", "malagasy"), - ("as", "assamese"), - ("tt", "tatar"), - ("haw", "hawaiian"), - ("ln", "lingala"), - ("ha", "hausa"), - ("ba", "bashkir"), - ("jw", "javanese"), - ("su", "sundanese"), -] \ No newline at end of file +# The supported languages are stored in the 'LANGUAGES' dictionary in the format of language code and language name. +# For example, the first element in the 'LANGUAGES' dictionary is ('en', 'english'). + +# To add support for a new model, we also need to update the `supportedLanguageList` array in the frontend TranscriptionLanguages.js file. +# https://github.com/learningequality/studio/blob/unstable/contentcuration/contentcuration/frontend/shared/leUtils/TranscriptionLanguages.js + +import json +import le_utils.resources as resources + +WHISPER_LANGUAGES = { + "en": "english", + "zh": "chinese", + "de": "german", + "es": "spanish", + "ru": "russian", + "ko": "korean", + "fr": "french", + "ja": "japanese", + "pt": "portuguese", + "tr": "turkish", + "pl": "polish", + "ca": "catalan", + "nl": "dutch", + "ar": "arabic", + "sv": "swedish", + "it": "italian", + "id": "indonesian", + "hi": "hindi", + "fi": "finnish", + "vi": "vietnamese", + "he": "hebrew", + "uk": "ukrainian", + "el": "greek", + "ms": "malay", + "cs": "czech", + "ro": "romanian", + "da": "danish", + "hu": "hungarian", + "ta": "tamil", + "no": "norwegian", + "th": "thai", + "ur": "urdu", + "hr": "croatian", + "bg": "bulgarian", + "lt": "lithuanian", + "la": "latin", + "mi": "maori", + "ml": "malayalam", + "cy": "welsh", + "sk": "slovak", + "te": "telugu", + "fa": "persian", + "lv": "latvian", + "bn": "bengali", + "sr": "serbian", + "az": "azerbaijani", + "sl": "slovenian", + "kn": "kannada", + "et": "estonian", + "mk": "macedonian", + "br": "breton", + "eu": "basque", + "is": "icelandic", + "hy": "armenian", + "ne": "nepali", + "mn": "mongolian", + "bs": "bosnian", + "kk": "kazakh", + "sq": "albanian", + "sw": "swahili", + "gl": "galician", + "mr": "marathi", + "pa": "punjabi", + "si": "sinhala", + "km": "khmer", + "sn": "shona", + "yo": "yoruba", + "so": "somali", + "af": "afrikaans", + "oc": "occitan", + "ka": "georgian", + "be": "belarusian", + "tg": "tajik", + "sd": "sindhi", + "gu": "gujarati", + "am": "amharic", + "yi": "yiddish", + "lo": "lao", + "uz": "uzbek", + "fo": "faroese", + "ht": "haitian creole", + "ps": "pashto", + "tk": "turkmen", + "nn": "nynorsk", + "mt": "maltese", + "sa": "sanskrit", + "lb": "luxembourgish", + "my": "myanmar", + "bo": "tibetan", + "tl": "tagalog", + "mg": "malagasy", + "as": "assamese", + "tt": "tatar", + "haw": "hawaiian", + "ln": "lingala", + "ha": "hausa", + "ba": "bashkir", + "jw": "javanese", + "su": "sundanese", +} + +def _load_kolibri_languages(): + """Load Kolibri languages from JSON file and return the language codes as a list.""" + filepath = resources.__path__[0] + kolibri_languages = [] + with open(f'{filepath}/languagelookup.json') as f: + kolibri_languages = list(json.load(f).keys()) + return kolibri_languages + +def _load_model_languages(languages): + """Load languages supported by the speech-to-text model.""" + return list(languages.keys()) + +def create_captions_languages(): + """Create the intersection of transcription model and Kolibri languages.""" + kolibri_set = set(_load_kolibri_languages()) + model_set = set(_load_model_languages(languages=WHISPER_LANGUAGES)) + return list(kolibri_set.intersection(model_set)) + +CAPTIONS_LANGUAGES = create_captions_languages() diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 91401ed102..dc55fe2485 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -22,7 +22,6 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index a99608b4a8..376542d8bd 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -1,15 +1,69 @@ +import { CaptionFile, CaptionCues } from 'shared/data/resources'; +import { GENERATING } from 'shared/data/constants' -export async function addCaptionFile({ commit }, { file_id, language, nodeId }) { - const captionFile = { - file_id: file_id, - language: language +export async function loadCaptionFiles(commit, params) { + const captionFiles = await CaptionFile.where(params); // We update the IndexedDB resource + commit('ADD_CAPTIONFILES', { captionFiles, nodeIds: params.contentnode_id }); // Now we update the vuex state + return captionFiles; +} + +export async function loadCaptionCues({ commit }, { caption_file_id }) { + const cues = await CaptionCues.where({ caption_file_id }); + commit('ADD_CAPTIONCUES', cues); + return cues; +} + +export async function loadCaptions({ commit, rootGetters }, params) { + const isAIFeatureEnabled = rootGetters['currentChannel/isAIFeatureEnabled']; + if (!isAIFeatureEnabled) return; + + // If a new file is uploaded, the contentnode_id will be string + if (typeof params.contentnode_id === 'string') { + params.contentnode_id = [params.contentnode_id]; + } + const nodeIdsToLoad = []; + for (const nodeId of params.contentnode_id) { + const node = rootGetters['contentNode/getContentNode'](nodeId); + if (node && (node.kind === 'video' || node.kind === 'audio')) { + nodeIdsToLoad.push(nodeId); // already in vuex + } else if (!node) { + nodeIdsToLoad.push(nodeId); // Assume that its audio/video } + } + + const captionFiles = await loadCaptionFiles(commit, { + contentnode_id: nodeIdsToLoad, + }); + + // If there is no Caption File for this contentnode + // Don't request for the cues + if (captionFiles.length === 0) return; + // TODO: call loadCaptionCues -> to be done after + // I finish saving captionFiles in indexedDB When + // CTA is called. So I have captions saved in the backend. +} + +export async function addCaptionFile({ state, commit }, { id, file_id, language, nodeId }) { + const captionFile = { + id: id, + file_id: file_id, + language: language, + }; + // The file_id and language should be unique together in the vuex state. This check avoids duplicating existing caption data already loaded into vuex. + const alreadyExists = state.captionFilesMap[nodeId] + ? Object.values(state.captionFilesMap[nodeId]).find( + file => file.language === captionFile.language && file.file_id === captionFile.file_id + ) + : null; + + if (!alreadyExists) { + // new created file will enqueue generate caption celery task + captionFile[GENERATING] = true; return CaptionFile.add(captionFile).then(id => { - captionFile.id = id; - console.log(captionFile, nodeId); - commit('ADD_CAPTIONFILE', { - captionFile, - nodeId - }); - }) -} \ No newline at end of file + commit('ADD_CAPTIONFILE', { + captionFile, + nodeId, + }); + }); + } +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js index 969306dfa3..6d87474e1d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js @@ -1,4 +1,6 @@ import Vue from "vue"; +import { GENERATING } from 'shared/data/constants' +// import { applyMods } from 'shared/data/applyRemoteChanges'; /* Mutations for Caption File */ export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { @@ -14,15 +16,11 @@ export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { Vue.set(state.captionFilesMap[nodeId], captionFile.id, {}); } - // Check if the file_id and language combination already exists - // const key = `${captionFile.file_id}_${captionFile.language}`; - // if(state.captionFilesMap[nodeId][captionFile.id]) { - // } - // Finally, set the file_id and language for that pk Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'id', captionFile.id); Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'file_id', captionFile.file_id); Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'language', captionFile.language); + Vue.set(state.captionFilesMap[nodeId][captionFile.id], GENERATING, captionFile[GENERATING]) } export function ADD_CAPTIONFILES(state, { captionFiles, nodeIds }) { @@ -46,3 +44,15 @@ export function ADD_CAPTIONCUES(state, { data } = []) { }) } } + +export function UPDATE_CAPTIONFILE_FROM_INDEXEDDB(state, { id, ...mods }) { + if(!id) return; + for (const nodeId in state.captionFilesMap) { + if (state.captionFilesMap[nodeId][id]) { + Vue.set(state.captionFilesMap[nodeId][id], GENERATING, mods[GENERATING]); + // updateCaptionCuesMaps(state, state.captionCuesMap[nodeId][id]); + break; + } + } + console.log('done'); +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/shared/data/constants.js b/contentcuration/contentcuration/frontend/shared/data/constants.js index f957130203..24c93a98ef 100644 --- a/contentcuration/contentcuration/frontend/shared/data/constants.js +++ b/contentcuration/contentcuration/frontend/shared/data/constants.js @@ -70,6 +70,7 @@ export const RELATIVE_TREE_POSITIONS_LOOKUP = invert(RELATIVE_TREE_POSITIONS); export const COPYING_FLAG = '__COPYING'; export const TASK_ID = '__TASK_ID'; export const LAST_FETCHED = '__last_fetch'; +export const GENERATING = '__generating_captions'; // This constant is used for saving/retrieving a current // user object from the session table diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 39cf44f17f..d05f8ba109 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -19,6 +19,7 @@ import { RELATIVE_TREE_POSITIONS, TABLE_NAMES, COPYING_FLAG, + GENERATING, TASK_ID, CURRENT_USER, MAX_REV_KEY, @@ -1024,6 +1025,31 @@ export const CaptionFile = new Resource({ indexFields: ['file_id', 'language'], syncable: true, getChannelId: getChannelFromChannelScope, + + waitForCaptionCueGeneration(id) { + const observable = Dexie.liveQuery(() => { + return this.table + .where('id') + .equals(id) + .filter(f => !f[GENERATING]) + .toArray(); + }); + + return new Promise((resolve, reject) => { + const subscription = observable.subscribe({ + next(result) { + if (result.length > 0 && result[0][GENERATING] === false) { + subscription.unsubscribe(); + resolve(false); + } + }, + error() { + subscription.unsubscribe(); + reject(); + }, + }); + }); + }, }); export const CaptionCues = new Resource({ diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py deleted file mode 100644 index b8176d6a02..0000000000 --- a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-26 17:33 - -import contentcuration.models -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='CaptionFile', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), - ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), - ], - options={ - 'unique_together': {('file_id', 'language')}, - }, - ), - migrations.CreateModel( - name='CaptionCue', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('text', models.TextField()), - ('starttime', models.FloatField()), - ('endtime', models.FloatField()), - ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), - ], - ), - ] diff --git a/contentcuration/contentcuration/tasks.py b/contentcuration/contentcuration/tasks.py index 39f89805ce..b2079a494b 100644 --- a/contentcuration/contentcuration/tasks.py +++ b/contentcuration/contentcuration/tasks.py @@ -137,3 +137,34 @@ def sendcustomemails_task(subject, message, query): text = message.format(current_date=time.strftime("%A, %B %d"), current_time=time.strftime("%H:%M %Z"), **recipient.__dict__) text = render_to_string('registration/custom_email.txt', {'message': text}) recipient.email_user(subject, text, settings.DEFAULT_FROM_EMAIL, ) + +@app.task(name="generatecaptioncues_task") +def generatecaptioncues_task(caption_file_id, channel_id, user_id): + """Start generating the Captions Cues for requested the Caption File""" + + import uuid + from contentcuration.viewsets.caption import CaptionCueSerializer + from contentcuration.viewsets.sync.utils import generate_update_event + from contentcuration.viewsets.sync.constants import CAPTION_FILE + + time.sleep(10) # AI model start generating + + cue = { + "id": uuid.uuid4().hex, + "text":"hello guys", + "starttime": 0, + "endtime": 5, + "caption_file_id": caption_file_id + } + + serializer = CaptionCueSerializer(data=cue) + if serializer.is_valid(): + instance = serializer.save() + Change.create_change(generate_update_event( + caption_file_id, + CAPTION_FILE, + {"__generating_captions": False}, + channel_id=channel_id, + ), applied=True, created_by_id=user_id) + else: + print(serializer.errors) diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 7d2a699230..b14ee5b944 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -87,6 +87,22 @@ def test_create_caption(self): self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) self.assertEqual(caption_file_db.language_id, caption_file["language"]) + def test_enqueue_caption_task(self): + self.client.force_authenticate(user=self.user) + caption_file = { + "file_id": uuid.uuid4().hex, + "language": Language.objects.get(pk="en").pk, + } + + response = self.sync_changes([generate_create_event( + uuid.uuid4().hex, + CAPTION_FILE, + caption_file, + channel_id=self.channel.id, + )],) + self.assertEqual(response.status_code, 200, response.content) + + def test_delete_caption_file(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index aed5948266..1d3e8f02c8 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,30 +1,18 @@ -from le_utils.constants.format_presets import ( - AUDIO, - VIDEO_HIGH_RES, - VIDEO_LOW_RES, -) +import logging + +from le_utils.constants.format_presets import AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES from rest_framework import serializers from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.utils import model_meta - -from contentcuration.models import CaptionCue, CaptionFile, File -from contentcuration.viewsets.base import ValuesViewset +from contentcuration.models import CaptionCue, CaptionFile, Change, File +from contentcuration.tasks import generatecaptioncues_task +from contentcuration.viewsets.base import BulkModelSerializer, ValuesViewset +from contentcuration.viewsets.sync.constants import CAPTION_FILE +from contentcuration.viewsets.sync.utils import generate_update_event -class CaptionCueSerializer(serializers.ModelSerializer): - class Meta: - model = CaptionCue - fields = ["text", "starttime", "endtime", "caption_file_id"] - - def validate(self, attrs): - """Check that the cue's starttime is before the endtime.""" - attrs = super().validate(attrs) - if attrs["starttime"] > attrs["endtime"]: - raise serializers.ValidationError("The cue must finish after start.") - return attrs - +class CaptionCueSerializer(BulkModelSerializer): class Meta: model = CaptionCue fields = ["text", "starttime", "endtime", "caption_file_id"] @@ -38,10 +26,10 @@ def validate(self, attrs): def to_internal_value(self, data): """ - Copies the caption_file_id from the request data + Copies the caption_file_id from the request data to the internal representation before validation. - - Without this, the caption_file_id would be lost + + Without this, the caption_file_id would be lost if validation fails, leading to errors. """ caption_file_id = data.get("caption_file_id") @@ -53,24 +41,12 @@ def to_internal_value(self, data): -class CaptionFileSerializer(serializers.ModelSerializer): +class CaptionFileSerializer(BulkModelSerializer): caption_cue = CaptionCueSerializer(many=True, required=False) class Meta: model = CaptionFile - fields = ["file_id", "language", "caption_cue"] - - @classmethod - def id_attr(cls): - """ - Returns the primary key name for the model class. - - Checks Meta.update_lookup_field to allow customizable - primary key names. Falls back to using the default "id". - """ - ModelClass = cls.Meta.model - info = model_meta.get_field_info(ModelClass) - return getattr(cls.Meta, "update_lookup_field", info.pk.name) + fields = ["id", "file_id", "language", "caption_cue"] class CaptionViewSet(ValuesViewset): @@ -93,7 +69,7 @@ def get_queryset(self): language = self.request.GET.get("language") if contentnode_ids: - contentnode_ids = contentnode_ids.split(',') + contentnode_ids = contentnode_ids.split(",") file_ids = File.objects.filter( preset_id__in=[AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES], contentnode_id__in=contentnode_ids, @@ -107,6 +83,32 @@ def get_queryset(self): return queryset + def perform_create(self, serializer, change=None): + instance = serializer.save() + Change.create_change( + generate_update_event( + instance.pk, + CAPTION_FILE, + { + "__generating_captions": True, + }, + channel_id=change['channel_id'] + ), applied=True, created_by_id=self.request.user.id + ) + + # enqueue task of generating captions for the saved CaptionFile instance + try: + # Also sets the generating flag to false <<< Generating Completeted + generatecaptioncues_task.enqueue( + self.request.user, + caption_file_id=instance.pk, + channel_id=change['channel_id'], + user_id=self.request.user.id + ) + + except Exception as e: + logging.error(f"Failed to queue celery task.\nWith the error: {e}") + class CaptionCueViewSet(ValuesViewset): # Handles operations for the CaptionCue model. diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py deleted file mode 100644 index 148a187698..0000000000 --- a/contentcuration/contentcuration/viewsets/captions.py +++ /dev/null @@ -1,27 +0,0 @@ -from rest_framework import serializers, status -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -# from contentcuration.models import GeneratedCaptions - - -class GeneratedCaptionsSerializer(serializers.ModelSerializer): - class Meta: - # model = GeneratedCaptions - fields = ['id', 'generated_captions', 'language'] - -class CaptionViewSet(ModelViewSet): - # queryset = GeneratedCaptions.objects.all() - serializer_class = GeneratedCaptionsSerializer - - def create(self, request): - # handles the creation operation and return serialized data - pass - - def update(self, request): - # handles the updating of an existing `GeneratedCaption` instance. - pass - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file From c52db7cfd223c0792deb5ed1ead052e087601b9f Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 23 May 2023 16:51:29 +0530 Subject: [PATCH 101/257] created captionviewset --- .../migrations/0143_generatedcaptions.py | 21 +++++++++++++++ contentcuration/contentcuration/urls.py | 1 + .../contentcuration/viewsets/captions.py | 27 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 contentcuration/contentcuration/migrations/0143_generatedcaptions.py create mode 100644 contentcuration/contentcuration/viewsets/captions.py diff --git a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py b/contentcuration/contentcuration/migrations/0143_generatedcaptions.py new file mode 100644 index 0000000000..0502d7e8bc --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_generatedcaptions.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.14 on 2023-05-23 11:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='GeneratedCaptions', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('generated_captions', models.JSONField()), + ('language', models.CharField(max_length=10)), + ], + ), + ] diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 692c8cc938..52b74bbf60 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,6 +32,7 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet +from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import AdminChannelViewSet diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py new file mode 100644 index 0000000000..84a5e3981c --- /dev/null +++ b/contentcuration/contentcuration/viewsets/captions.py @@ -0,0 +1,27 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from contentcuration.models import GeneratedCaptions + + +class GeneratedCaptionsSerializer(serializers.ModelSerializer): + class Meta: + model = GeneratedCaptions + fields = ['id', 'generated_captions', 'language'] + +class CaptionViewSet(ModelViewSet): + queryset = GeneratedCaptions.objects.all() + serializer_class = GeneratedCaptionsSerializer + + def create(self, request): + # handles the creation operation and return serialized data + pass + + def update(self, request): + # handles the updating of an existing `GeneratedCaption` instance. + pass + + def destroy(self, request): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file From 117f0a9cd8f1e42e50c9ee8f8e77f9afd46e2e97 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Mon, 19 Jun 2023 12:40:39 +0530 Subject: [PATCH 102/257] Adds captions modal with visibility controlled by featureflag --- .../CaptionsEditor/CaptionsEditor.vue | 19 +++++++++++++ ...3_generatedcaptions.py => 0143_caption.py} | 10 ++++--- contentcuration/contentcuration/urls.py | 1 - .../contentcuration/viewsets/captions.py | 27 ------------------- 4 files changed, 25 insertions(+), 32 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue rename contentcuration/contentcuration/migrations/{0143_generatedcaptions.py => 0143_caption.py} (53%) delete mode 100644 contentcuration/contentcuration/viewsets/captions.py diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue new file mode 100644 index 0000000000..33f25de838 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py b/contentcuration/contentcuration/migrations/0143_caption.py similarity index 53% rename from contentcuration/contentcuration/migrations/0143_generatedcaptions.py rename to contentcuration/contentcuration/migrations/0143_caption.py index 0502d7e8bc..3d6e0e769c 100644 --- a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py +++ b/contentcuration/contentcuration/migrations/0143_caption.py @@ -1,6 +1,8 @@ -# Generated by Django 3.2.14 on 2023-05-23 11:00 +# Generated by Django 3.2.14 on 2023-06-15 06:13 +import contentcuration.models from django.db import migrations, models +import uuid class Migration(migrations.Migration): @@ -11,10 +13,10 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='GeneratedCaptions', + name='Caption', fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('generated_captions', models.JSONField()), + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('caption', models.JSONField()), ('language', models.CharField(max_length=10)), ], ), diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 52b74bbf60..692c8cc938 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,7 +32,6 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet -from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import AdminChannelViewSet diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py deleted file mode 100644 index 84a5e3981c..0000000000 --- a/contentcuration/contentcuration/viewsets/captions.py +++ /dev/null @@ -1,27 +0,0 @@ -from rest_framework import serializers, status -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from contentcuration.models import GeneratedCaptions - - -class GeneratedCaptionsSerializer(serializers.ModelSerializer): - class Meta: - model = GeneratedCaptions - fields = ['id', 'generated_captions', 'language'] - -class CaptionViewSet(ModelViewSet): - queryset = GeneratedCaptions.objects.all() - serializer_class = GeneratedCaptionsSerializer - - def create(self, request): - # handles the creation operation and return serialized data - pass - - def update(self, request): - # handles the updating of an existing `GeneratedCaption` instance. - pass - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file From e5a61ebd1e243fc365e48959c1bcb5e8daba2e6d Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:29:13 +0530 Subject: [PATCH 103/257] Adds Sync API tests for CaptionFile ViewSet --- .../migrations/0143_caption.py | 23 ------------ .../migrations/0143_captioncue_captionfile.py | 37 +++++++++++++++++++ contentcuration/contentcuration/models.py | 1 + 3 files changed, 38 insertions(+), 23 deletions(-) delete mode 100644 contentcuration/contentcuration/migrations/0143_caption.py create mode 100644 contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py diff --git a/contentcuration/contentcuration/migrations/0143_caption.py b/contentcuration/contentcuration/migrations/0143_caption.py deleted file mode 100644 index 3d6e0e769c..0000000000 --- a/contentcuration/contentcuration/migrations/0143_caption.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-15 06:13 - -import contentcuration.models -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='Caption', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('caption', models.JSONField()), - ('language', models.CharField(max_length=10)), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py new file mode 100644 index 0000000000..b8176d6a02 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-06-26 17:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index c4f55d1eb3..9f43d55db8 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,6 +2,7 @@ import json import logging import os +from typing import Any import urllib.parse import uuid from datetime import datetime From 49c3c81fd5615162483b46a34d17def36ecde581 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:33:46 +0530 Subject: [PATCH 104/257] Removes unnecessary imports --- contentcuration/contentcuration/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 9f43d55db8..c4f55d1eb3 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,7 +2,6 @@ import json import logging import os -from typing import Any import urllib.parse import uuid from datetime import datetime From 42e13637656edf94e84dd0995a1678f065af0170 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:11:16 +0530 Subject: [PATCH 105/257] Creating CaptionCue with generate_create_event fails --- .../components/CaptionsEditor/CaptionsEditor.vue | 2 +- .../tests/viewsets/test_caption.py | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 33f25de838..531dc7d90f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -16,4 +16,4 @@ \ No newline at end of file + diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index b14ee5b944..acd0e42f19 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -52,12 +52,25 @@ def caption_cue_metadata(self): }, } + @property + def caption_cue_metadata(self): + return { + "file": { + "file_id": uuid.uuid4().hex, + "language": "en", + }, + "cue": { + "text": "This is the beginning!", + "starttime": 0.0, + "endtime": 12.0, + }, + } + def setUp(self): super(SyncTestCase, self).setUp() self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) From 6da0cada2e4979e9c3c55e07d546f36c38fdc3cb Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:46:25 +0530 Subject: [PATCH 106/257] Add failing test for CaptionFile JSON serialization --- .../contentcuration/viewsets/caption.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 1d3e8f02c8..ca716b9302 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD import logging from le_utils.constants.format_presets import AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES @@ -13,6 +14,18 @@ class CaptionCueSerializer(BulkModelSerializer): +======= +from django.core.exceptions import ObjectDoesNotExist +from django.http import JsonResponse +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.viewsets.base import ValuesViewset +from contentcuration.viewsets.sync.utils import log_sync_exception + +class CaptionCueSerializer(serializers.ModelSerializer): +>>>>>>> 6bfd92767 (Add failing test for CaptionFile JSON serialization) class Meta: model = CaptionCue fields = ["text", "starttime", "endtime", "caption_file_id"] @@ -39,9 +52,13 @@ def to_internal_value(self, data): value["caption_file_id"] = caption_file_id return value +<<<<<<< HEAD class CaptionFileSerializer(BulkModelSerializer): +======= +class CaptionFileSerializer(serializers.ModelSerializer): +>>>>>>> 6bfd92767 (Add failing test for CaptionFile JSON serialization) caption_cue = CaptionCueSerializer(many=True, required=False) class Meta: @@ -54,11 +71,19 @@ class CaptionViewSet(ValuesViewset): queryset = CaptionFile.objects.prefetch_related("caption_cue") permission_classes = [IsAuthenticated] serializer_class = CaptionFileSerializer +<<<<<<< HEAD values = ("id", "file_id", "language") field_map = { "file_id": "file_id", "language": "language", +======= + values = ("file_id", "language", "caption_cue") + + field_map = { + "file": "file_id", + "language": "language" +>>>>>>> 6bfd92767 (Add failing test for CaptionFile JSON serialization) } def get_queryset(self): From 312aea1e835e8cd01966850f292b4db3323593ad Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 7 Jul 2023 13:12:04 +0530 Subject: [PATCH 107/257] Adds caption editor components, updated IndexedDB Resource --- .../CaptionsEditor/CaptionsEditor.vue | 15 ++++++++--- .../channelEdit/vuex/caption/mutations.js | 2 +- .../tests/viewsets/test_caption.py | 3 ++- .../contentcuration/viewsets/caption.py | 25 ------------------- 4 files changed, 14 insertions(+), 31 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 531dc7d90f..4efd617226 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -1,17 +1,24 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 4cbf88eadd..91401ed102 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -25,7 +25,6 @@ import { mapGetters, mapActions } from 'vuex'; import EditListItem from './EditListItem'; import Checkbox from 'shared/views/form/Checkbox'; - import { loadCaption } from '../../utils'; export default { name: 'EditList', @@ -44,16 +43,15 @@ }, }, watch: { - // watch the selected contentnode + // watch the selected contentnode to load captions value(newValue) { - if (this.isAIFeatureEnabled && newValue.length === 1) { - const nodeId = newValue[0]; - loadCaption([nodeId], this.loadCaptionFiles, this.loadCaptionCues); + if (newValue.length === 1) { + const nodeId = newValue[0]; // we dispatch action only when 1 node is selected + this.loadCaptions({ contentnode_id: nodeId }) } } }, computed: { - ...mapGetters('currentChannel', ['isAIFeatureEnabled']), selected: { get() { return this.value; @@ -77,7 +75,7 @@ }, }, methods: { - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), handleRemoved(nodeId) { const nodeIds = this.$route.params.detailNodeIds.split(',').filter(id => id !== nodeId); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index c88c12a3be..c7e1ec645d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -197,7 +197,6 @@ import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; import { DELAYED_VALIDATION } from 'shared/constants'; - import { loadCaption } from '../../utils'; const CHECK_STORAGE_INTERVAL = 10000; @@ -251,7 +250,7 @@ computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), - ...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'isAIFeatureEnabled']), + ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), ...mapGetters('file', ['contentNodesAreUploading', 'getContentNodeFiles']), ...mapState({ online: state => state.connection.online, @@ -307,11 +306,6 @@ return this.nodeIds.filter(id => !this.getContentNodeIsValid(id)); }, }, - created() { - if (this.isAIFeatureEnabled && this.nodeIds.length === 1) { - loadCaption(this.nodeIds, this.loadCaptionFiles, this.loadCaptionCues); - } - }, beforeRouteEnter(to, from, next) { if ( to.name === RouteNames.CONTENTNODE_DETAILS || @@ -348,6 +342,11 @@ vm.loadAssessmentItems({ contentnode__in: childrenNodesIds }), vm.loadCaptions({ contentnode_id: childrenNodesIds }) ]; + if(childrenNodesIds.length === 1) { + promises.push( + vm.loadCaptions({ contentnode_id: childrenNodesIds }) + ); + } } else { // no need to load assessment items or files as topics have none promises = [vm.loadContentNode(parentTopicId)]; diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 3ab58f3a6b..97dd9d4713 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,4 +1,3 @@ -import store from './store'; import translator from './translator'; import { RouteNames } from './constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -390,28 +389,3 @@ export function getCompletionCriteriaLabels(node = {}, files = []) { return labels; } - -/** - * Loads caption files and cues for a content node. - * - * Caption files and cues are only loaded when: - * - The AI feature flag is enabled - * - A single node is being edited - * - The node kind is 'video' or 'audio' - * - * @param {Array} nodeIds - Array containing node ID(s) being edited - * @param {Function} loadCaptionFiles - Vuex Action to load caption files - * @param {Function} loadCaptionCues - Vuex Action to load caption cues - */ -export function loadCaption(nodeIds, loadCaptionFiles, loadCaptionCues) { - - const CONTENTNODEID = nodeIds[0]; - const KIND = store.getters["contentNode/getContentNode"](CONTENTNODEID).kind; - - if(KIND === 'video' || KIND === 'audio') { - loadCaptionFiles({ contentnode_id: CONTENTNODEID }).then(captionFile => { - let captionFileID = captionFile[0].id; // FK to retrieve the cues of a caption file - loadCaptionCues({ caption_file_id: captionFileID }) - }) - } -} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index 376542d8bd..966a1b76fd 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -67,3 +67,21 @@ export async function addCaptionFile({ state, commit }, { id, file_id, language, }); } } + +export async function loadCaptions({ commit, rootGetters }, params) { + const AI_FEATURE_FLAG = rootGetters['currentChannel/isAIFeatureEnabled'] + if(!AI_FEATURE_FLAG) return; + + const FILE_TYPE = rootGetters['contentNode/getContentNode'](params.contentnode_id).kind; + if(FILE_TYPE === 'video' || FILE_TYPE === 'audio') { + const captionFiles = await loadCaptionFiles(commit, params); + // If there is no Caption File for this contentnode + // Don't request for the cues + if(captionFiles.length === 0) return; + // TODO: call loadCaptionCues -> to be done after I finish saving captionFiles in indexedDB When CTA is called. So I have captions saved in the backend. + } +} + +export async function addCaptionFile({ commit }, { captionFile, nodeId }) { + commit('ADD_CAPTIONFILE', { captionFile, nodeId }); +} \ No newline at end of file From 3787cd53809cd2fb9636d2dd556e8dae99928662 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Thu, 27 Jul 2023 22:46:37 +0530 Subject: [PATCH 113/257] Stage changes before rebase --- .../components/CaptionsTab/languages.js | 101 ++++++++++++++++++ .../channelEdit/components/edit/EditList.vue | 11 -- .../channelEdit/components/edit/EditModal.vue | 5 - .../channelEdit/vuex/caption/actions.js | 48 +++++++-- 4 files changed, 138 insertions(+), 27 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js new file mode 100644 index 0000000000..703d856715 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/languages.js @@ -0,0 +1,101 @@ +export const CAPTIONS_LANGUAGES = { + en: "english", + zh: "chinese", + de: "german", + es: "spanish", + ru: "russian", + ko: "korean", + fr: "french", + ja: "japanese", + pt: "portuguese", + tr: "turkish", + pl: "polish", + ca: "catalan", + nl: "dutch", + ar: "arabic", + sv: "swedish", + it: "italian", + id: "indonesian", + hi: "hindi", + fi: "finnish", + vi: "vietnamese", + he: "hebrew", + uk: "ukrainian", + el: "greek", + ms: "malay", + cs: "czech", + ro: "romanian", + da: "danish", + hu: "hungarian", + ta: "tamil", + no: "norwegian", + th: "thai", + ur: "urdu", + hr: "croatian", + bg: "bulgarian", + lt: "lithuanian", + la: "latin", + mi: "maori", + ml: "malayalam", + cy: "welsh", + sk: "slovak", + te: "telugu", + fa: "persian", + lv: "latvian", + bn: "bengali", + sr: "serbian", + az: "azerbaijani", + sl: "slovenian", + kn: "kannada", + et: "estonian", + mk: "macedonian", + br: "breton", + eu: "basque", + is: "icelandic", + hy: "armenian", + ne: "nepali", + mn: "mongolian", + bs: "bosnian", + kk: "kazakh", + sq: "albanian", + sw: "swahili", + gl: "galician", + mr: "marathi", + pa: "punjabi", + si: "sinhala", + km: "khmer", + sn: "shona", + yo: "yoruba", + so: "somali", + af: "afrikaans", + oc: "occitan", + ka: "georgian", + be: "belarusian", + tg: "tajik", + sd: "sindhi", + gu: "gujarati", + am: "amharic", + yi: "yiddish", + lo: "lao", + uz: "uzbek", + fo: "faroese", + ht: "haitian creole", + ps: "pashto", + tk: "turkmen", + nn: "nynorsk", + mt: "maltese", + sa: "sanskrit", + lb: "luxembourgish", + my: "myanmar", + bo: "tibetan", + tl: "tagalog", + mg: "malagasy", + as: "assamese", + tt: "tatar", + haw: "hawaiian", + ln: "lingala", + ha: "hausa", + ba: "bashkir", + jw: "javanese", + su: "sundanese", +} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 91401ed102..dc55fe2485 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -22,7 +22,6 @@ + + \ No newline at end of file diff --git a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py b/contentcuration/contentcuration/migrations/0143_caption.py similarity index 53% rename from contentcuration/contentcuration/migrations/0143_generatedcaptions.py rename to contentcuration/contentcuration/migrations/0143_caption.py index 0502d7e8bc..3d6e0e769c 100644 --- a/contentcuration/contentcuration/migrations/0143_generatedcaptions.py +++ b/contentcuration/contentcuration/migrations/0143_caption.py @@ -1,6 +1,8 @@ -# Generated by Django 3.2.14 on 2023-05-23 11:00 +# Generated by Django 3.2.14 on 2023-06-15 06:13 +import contentcuration.models from django.db import migrations, models +import uuid class Migration(migrations.Migration): @@ -11,10 +13,10 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='GeneratedCaptions', + name='Caption', fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('generated_captions', models.JSONField()), + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('caption', models.JSONField()), ('language', models.CharField(max_length=10)), ], ), diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 52b74bbf60..692c8cc938 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -32,7 +32,6 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet -from contentcuration.viewsets.captions import CaptionViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet from contentcuration.viewsets.channel import AdminChannelViewSet diff --git a/contentcuration/contentcuration/viewsets/captions.py b/contentcuration/contentcuration/viewsets/captions.py deleted file mode 100644 index 84a5e3981c..0000000000 --- a/contentcuration/contentcuration/viewsets/captions.py +++ /dev/null @@ -1,27 +0,0 @@ -from rest_framework import serializers, status -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet -from contentcuration.models import GeneratedCaptions - - -class GeneratedCaptionsSerializer(serializers.ModelSerializer): - class Meta: - model = GeneratedCaptions - fields = ['id', 'generated_captions', 'language'] - -class CaptionViewSet(ModelViewSet): - queryset = GeneratedCaptions.objects.all() - serializer_class = GeneratedCaptionsSerializer - - def create(self, request): - # handles the creation operation and return serialized data - pass - - def update(self, request): - # handles the updating of an existing `GeneratedCaption` instance. - pass - - def destroy(self, request): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index 800e1499ac..7606853bcc 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -5,11 +5,7 @@ from contentcuration.decorators import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet -<<<<<<< HEAD from contentcuration.viewsets.caption import CaptionViewSet, CaptionCueViewSet -======= -from contentcuration.viewsets.captions import CaptionViewSet ->>>>>>> 0e3345989 (created captionviewset) from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet @@ -19,12 +15,8 @@ from contentcuration.viewsets.invitation import InvitationViewSet from contentcuration.viewsets.sync.constants import ASSESSMENTITEM from contentcuration.viewsets.sync.constants import BOOKMARK -<<<<<<< HEAD from contentcuration.viewsets.sync.constants import CAPTION_CUES from contentcuration.viewsets.sync.constants import CAPTION_FILE -======= -from contentcuration.viewsets.sync.constants import CAPTION ->>>>>>> 0e3345989 (created captionviewset) from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import CHANNELSET from contentcuration.viewsets.sync.constants import CLIPBOARD From 02de49723c9ad81163747ebe85e8e49710c12dd5 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:29:13 +0530 Subject: [PATCH 117/257] Adds Sync API tests for CaptionFile ViewSet --- .../migrations/0143_caption.py | 23 ------------ .../migrations/0143_captioncue_captionfile.py | 37 +++++++++++++++++++ contentcuration/contentcuration/models.py | 1 + 3 files changed, 38 insertions(+), 23 deletions(-) delete mode 100644 contentcuration/contentcuration/migrations/0143_caption.py create mode 100644 contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py diff --git a/contentcuration/contentcuration/migrations/0143_caption.py b/contentcuration/contentcuration/migrations/0143_caption.py deleted file mode 100644 index 3d6e0e769c..0000000000 --- a/contentcuration/contentcuration/migrations/0143_caption.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-15 06:13 - -import contentcuration.models -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='Caption', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('caption', models.JSONField()), - ('language', models.CharField(max_length=10)), - ], - ), - ] diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py new file mode 100644 index 0000000000..b8176d6a02 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.14 on 2023-06-26 17:33 + +import contentcuration.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0142_add_task_signature'), + ] + + operations = [ + migrations.CreateModel( + name='CaptionFile', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), + ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), + ], + options={ + 'unique_together': {('file_id', 'language')}, + }, + ), + migrations.CreateModel( + name='CaptionCue', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('starttime', models.FloatField()), + ('endtime', models.FloatField()), + ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), + ], + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index c4f55d1eb3..9f43d55db8 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,6 +2,7 @@ import json import logging import os +from typing import Any import urllib.parse import uuid from datetime import datetime From 51e50d4259311f655c729907d78d2fd44826c106 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Tue, 27 Jun 2023 20:33:46 +0530 Subject: [PATCH 118/257] Removes unnecessary imports --- contentcuration/contentcuration/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 9f43d55db8..c4f55d1eb3 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2,7 +2,6 @@ import json import logging import os -from typing import Any import urllib.parse import uuid from datetime import datetime From ca52dbf9f9a05ec4e1e85d15469d36e028a41c6a Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:11:16 +0530 Subject: [PATCH 119/257] Creating CaptionCue with generate_create_event fails --- .../CaptionsEditor/CaptionsEditor.vue | 2 +- .../tests/viewsets/test_caption.py | 38 +++++++++---------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 33f25de838..531dc7d90f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -16,4 +16,4 @@ \ No newline at end of file + diff --git a/contentcuration/contentcuration/tests/viewsets/test_caption.py b/contentcuration/contentcuration/tests/viewsets/test_caption.py index 9d2a568547..7487f892ad 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_caption.py +++ b/contentcuration/contentcuration/tests/viewsets/test_caption.py @@ -66,12 +66,26 @@ def caption_cue_metadata(self): }, } + @property + def caption_cue_metadata(self): + return { + "file": { + "file_id": uuid.uuid4().hex, + "language": "en", + }, + "cue": { + "text": "This is the beginning!", + "starttime": 0.0, + "endtime": 12.0, + }, + } + def setUp(self): super(SyncTestCase, self).setUp() self.channel = testdata.channel() self.user = testdata.user() self.channel.editors.add(self.user) - + # Test for CaptionFile model def test_create_caption(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata @@ -99,7 +113,6 @@ def test_create_caption(self): # Check the values of the object in the PostgreSQL self.assertEqual(caption_file_db.file_id, caption_file["file_id"]) self.assertEqual(caption_file_db.language_id, caption_file["language"]) -<<<<<<< HEAD def test_enqueue_caption_task(self): self.client.force_authenticate(user=self.user) @@ -116,22 +129,13 @@ def test_enqueue_caption_task(self): )],) self.assertEqual(response.status_code, 200, response.content) -======= ->>>>>>> 31ca620e6 (Refactor constants and integrate with Vue comp.) def test_delete_caption_file(self): self.client.force_authenticate(user=self.user) caption_file = self.caption_file_metadata -<<<<<<< HEAD -<<<<<<< HEAD - # Explicitly set language to model object to follow Django ORM conventions - caption_file['language'] = Language.objects.get(pk='en') -======= ->>>>>>> c94a30ba1 (Refactor id_attr method for retrieving identifier attribute in delete_from_changes) -======= + # Explicitly set language to model object to follow Django ORM conventions caption_file['language'] = Language.objects.get(pk='en') ->>>>>>> 31ca620e6 (Refactor constants and integrate with Vue comp.) caption_file_1 = CaptionFile(**caption_file) pk = caption_file_1.pk @@ -205,16 +209,10 @@ def test_caption_cue_serialization(self): def test_create_caption_cue(self): self.client.force_authenticate(user=self.user) metadata = self.caption_cue_metadata -<<<<<<< HEAD -<<<<<<< HEAD - # Explicitly set language to model object to follow Django ORM conventions - metadata['file']['language'] = Language.objects.get(pk="en") -======= ->>>>>>> c94a30ba1 (Refactor id_attr method for retrieving identifier attribute in delete_from_changes) -======= + # Explicitly set language to model object to follow Django ORM conventions metadata['file']['language'] = Language.objects.get(pk="en") ->>>>>>> 31ca620e6 (Refactor constants and integrate with Vue comp.) + caption_file_1 = CaptionFile.objects.create(**metadata["file"]) caption_cue = metadata["cue"] caption_cue["caption_file_id"] = caption_file_1.pk From 5c2a0d80fc821338611ab46e9000d0073d0fcaaa Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 30 Jun 2023 16:46:25 +0530 Subject: [PATCH 120/257] Add failing test for CaptionFile JSON serialization --- .../contentcuration/viewsets/caption.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 1d3e8f02c8..585804bc5a 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD import logging from le_utils.constants.format_presets import AUDIO, VIDEO_HIGH_RES, VIDEO_LOW_RES @@ -13,6 +14,18 @@ class CaptionCueSerializer(BulkModelSerializer): +======= +from django.core.exceptions import ObjectDoesNotExist +from django.http import JsonResponse +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from contentcuration.models import CaptionCue, CaptionFile +from contentcuration.viewsets.base import ValuesViewset +from contentcuration.viewsets.sync.utils import log_sync_exception + +class CaptionCueSerializer(serializers.ModelSerializer): +>>>>>>> c8fea0e73 (Add failing test for CaptionFile JSON serialization) class Meta: model = CaptionCue fields = ["text", "starttime", "endtime", "caption_file_id"] @@ -39,9 +52,13 @@ def to_internal_value(self, data): value["caption_file_id"] = caption_file_id return value +<<<<<<< HEAD class CaptionFileSerializer(BulkModelSerializer): +======= +class CaptionFileSerializer(serializers.ModelSerializer): +>>>>>>> c8fea0e73 (Add failing test for CaptionFile JSON serialization) caption_cue = CaptionCueSerializer(many=True, required=False) class Meta: @@ -54,11 +71,19 @@ class CaptionViewSet(ValuesViewset): queryset = CaptionFile.objects.prefetch_related("caption_cue") permission_classes = [IsAuthenticated] serializer_class = CaptionFileSerializer +<<<<<<< HEAD values = ("id", "file_id", "language") field_map = { "file_id": "file_id", "language": "language", +======= + values = ("file_id", "language", "caption_cue") + + field_map = { + "file": "file_id", + "language": "language" +>>>>>>> c8fea0e73 (Add failing test for CaptionFile JSON serialization) } def get_queryset(self): From 695f2214ed945b2e9c1ec5cfc39f46b4632b8005 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Fri, 7 Jul 2023 13:12:04 +0530 Subject: [PATCH 121/257] Adds caption editor components, updated IndexedDB Resource --- .../CaptionsEditor/CaptionsEditor.vue | 15 ++++++++--- .../channelEdit/vuex/caption/actions.js | 2 +- .../tests/viewsets/test_caption.py | 1 + .../contentcuration/viewsets/caption.py | 25 ------------------- 4 files changed, 13 insertions(+), 30 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue index 531dc7d90f..4efd617226 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsEditor/CaptionsEditor.vue @@ -1,17 +1,24 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 4cbf88eadd..91401ed102 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -25,7 +25,6 @@ import { mapGetters, mapActions } from 'vuex'; import EditListItem from './EditListItem'; import Checkbox from 'shared/views/form/Checkbox'; - import { loadCaption } from '../../utils'; export default { name: 'EditList', @@ -44,16 +43,15 @@ }, }, watch: { - // watch the selected contentnode + // watch the selected contentnode to load captions value(newValue) { - if (this.isAIFeatureEnabled && newValue.length === 1) { - const nodeId = newValue[0]; - loadCaption([nodeId], this.loadCaptionFiles, this.loadCaptionCues); + if (newValue.length === 1) { + const nodeId = newValue[0]; // we dispatch action only when 1 node is selected + this.loadCaptions({ contentnode_id: nodeId }) } } }, computed: { - ...mapGetters('currentChannel', ['isAIFeatureEnabled']), selected: { get() { return this.value; @@ -77,7 +75,7 @@ }, }, methods: { - ...mapActions('caption', ['loadCaptionFiles', 'loadCaptionCues']), + ...mapActions('caption', ['loadCaptions']), handleRemoved(nodeId) { const nodeIds = this.$route.params.detailNodeIds.split(',').filter(id => id !== nodeId); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index c88c12a3be..c7e1ec645d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -197,7 +197,6 @@ import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; import { DELAYED_VALIDATION } from 'shared/constants'; - import { loadCaption } from '../../utils'; const CHECK_STORAGE_INTERVAL = 10000; @@ -251,7 +250,7 @@ computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), - ...mapGetters('currentChannel', ['currentChannel', 'canEdit', 'isAIFeatureEnabled']), + ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), ...mapGetters('file', ['contentNodesAreUploading', 'getContentNodeFiles']), ...mapState({ online: state => state.connection.online, @@ -307,11 +306,6 @@ return this.nodeIds.filter(id => !this.getContentNodeIsValid(id)); }, }, - created() { - if (this.isAIFeatureEnabled && this.nodeIds.length === 1) { - loadCaption(this.nodeIds, this.loadCaptionFiles, this.loadCaptionCues); - } - }, beforeRouteEnter(to, from, next) { if ( to.name === RouteNames.CONTENTNODE_DETAILS || @@ -348,6 +342,11 @@ vm.loadAssessmentItems({ contentnode__in: childrenNodesIds }), vm.loadCaptions({ contentnode_id: childrenNodesIds }) ]; + if(childrenNodesIds.length === 1) { + promises.push( + vm.loadCaptions({ contentnode_id: childrenNodesIds }) + ); + } } else { // no need to load assessment items or files as topics have none promises = [vm.loadContentNode(parentTopicId)]; diff --git a/contentcuration/contentcuration/frontend/channelEdit/utils.js b/contentcuration/contentcuration/frontend/channelEdit/utils.js index 3ab58f3a6b..97dd9d4713 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/utils.js +++ b/contentcuration/contentcuration/frontend/channelEdit/utils.js @@ -1,4 +1,3 @@ -import store from './store'; import translator from './translator'; import { RouteNames } from './constants'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; @@ -390,28 +389,3 @@ export function getCompletionCriteriaLabels(node = {}, files = []) { return labels; } - -/** - * Loads caption files and cues for a content node. - * - * Caption files and cues are only loaded when: - * - The AI feature flag is enabled - * - A single node is being edited - * - The node kind is 'video' or 'audio' - * - * @param {Array} nodeIds - Array containing node ID(s) being edited - * @param {Function} loadCaptionFiles - Vuex Action to load caption files - * @param {Function} loadCaptionCues - Vuex Action to load caption cues - */ -export function loadCaption(nodeIds, loadCaptionFiles, loadCaptionCues) { - - const CONTENTNODEID = nodeIds[0]; - const KIND = store.getters["contentNode/getContentNode"](CONTENTNODEID).kind; - - if(KIND === 'video' || KIND === 'audio') { - loadCaptionFiles({ contentnode_id: CONTENTNODEID }).then(captionFile => { - let captionFileID = captionFile[0].id; // FK to retrieve the cues of a caption file - loadCaptionCues({ caption_file_id: captionFileID }) - }) - } -} diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index 21bde07a83..2f964baae8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -113,3 +113,21 @@ export async function addCaptionFile({ commit }, { file_id, language, nodeId }) }); }) } + +export async function loadCaptions({ commit, rootGetters }, params) { + const AI_FEATURE_FLAG = rootGetters['currentChannel/isAIFeatureEnabled'] + if(!AI_FEATURE_FLAG) return; + + const FILE_TYPE = rootGetters['contentNode/getContentNode'](params.contentnode_id).kind; + if(FILE_TYPE === 'video' || FILE_TYPE === 'audio') { + const captionFiles = await loadCaptionFiles(commit, params); + // If there is no Caption File for this contentnode + // Don't request for the cues + if(captionFiles.length === 0) return; + // TODO: call loadCaptionCues -> to be done after I finish saving captionFiles in indexedDB When CTA is called. So I have captions saved in the backend. + } +} + +export async function addCaptionFile({ commit }, { captionFile, nodeId }) { + commit('ADD_CAPTIONFILE', { captionFile, nodeId }); +} \ No newline at end of file From 57d158b804e665a1af1c477840673ece7fed98e7 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Wed, 2 Aug 2023 00:27:10 +0530 Subject: [PATCH 126/257] fixs merge conflict --- .../frontend/channelEdit/components/edit/EditList.vue | 11 ----------- .../channelEdit/components/edit/EditModal.vue | 5 ----- 2 files changed, 16 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index 91401ed102..dc55fe2485 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -22,7 +22,6 @@ diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 5d7c934e67..7a1e7823b2 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -340,7 +340,7 @@ // (especially marking nodes as (in)complete) vm.loadFiles({ contentnode__in: childrenNodesIds }), vm.loadAssessmentItems({ contentnode__in: childrenNodesIds }), - vm.loadCaptions({ contentnode_id: childrenNodesIds }) + vm.loadCaptions({ contentnode__in: childrenNodesIds }), ]; } else { // no need to load assessment items or files as topics have none diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js index 2f964baae8..d4a056802a 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/actions.js @@ -1,13 +1,13 @@ import { CaptionFile, CaptionCues } from 'shared/data/resources'; -import { GENERATING } from 'shared/data/constants' +import { GENERATING } from 'shared/data/constants'; export async function loadCaptionFiles(commit, params) { - const captionFiles = await CaptionFile.where(params); // We update the IndexedDB resource - commit('ADD_CAPTIONFILES', { captionFiles, nodeIds: params.contentnode_id }); // Now we update the vuex state + const captionFiles = await CaptionFile.where(params); + commit('ADD_CAPTIONFILES', { captionFiles, nodeIds: params.contentnode__in }); return captionFiles; } -export async function loadCaptionCues({ commit }, { caption_file_id }) { +export async function loadCaptionCues(commit, { caption_file_id }) { const cues = await CaptionCues.where({ caption_file_id }); commit('ADD_CAPTIONCUES', cues); return cues; @@ -18,11 +18,11 @@ export async function loadCaptions({ commit, rootGetters }, params) { if (!isAIFeatureEnabled) return; // If a new file is uploaded, the contentnode_id will be string - if (typeof params.contentnode_id === 'string') { - params.contentnode_id = [params.contentnode_id]; + if (typeof params.contentnode__in === 'string') { + params.contentnode__in = [params.contentnode__in]; } const nodeIdsToLoad = []; - for (const nodeId of params.contentnode_id) { + for (const nodeId of params.contentnode__in) { const node = rootGetters['contentNode/getContentNode'](nodeId); if (node && (node.kind === 'video' || node.kind === 'audio')) { nodeIdsToLoad.push(nodeId); // already in vuex @@ -32,15 +32,19 @@ export async function loadCaptions({ commit, rootGetters }, params) { } const captionFiles = await loadCaptionFiles(commit, { - contentnode_id: nodeIdsToLoad, + contentnode__in: nodeIdsToLoad, }); - // If there is no Caption File for this contentnode - // Don't request for the cues + // If there is no Caption File for this contentnode don't request for the cues if (captionFiles.length === 0) return; - // TODO: call loadCaptionCues -> to be done after - // I finish saving captionFiles in indexedDB When - // CTA is called. So I have captions saved in the backend. + + captionFiles.forEach(file => { + // Load all the cues associated with the file_id + const caption_file_id = file.id; + loadCaptionCues(commit, { + caption_file_id, + }); + }); } export async function addCaptionFile({ state, commit }, { id, file_id, language, nodeId }) { @@ -49,7 +53,8 @@ export async function addCaptionFile({ state, commit }, { id, file_id, language, file_id: file_id, language: language, }; - // The file_id and language should be unique together in the vuex state. This check avoids duplicating existing caption data already loaded into vuex. + // The file_id and language should be unique together in the vuex state. + // This check avoids duplicating existing caption data already loaded into vuex. const alreadyExists = state.captionFilesMap[nodeId] ? Object.values(state.captionFilesMap[nodeId]).find( file => file.language === captionFile.language && file.file_id === captionFile.file_id @@ -61,73 +66,10 @@ export async function addCaptionFile({ state, commit }, { id, file_id, language, captionFile[GENERATING] = true; return CaptionFile.add(captionFile).then(id => { commit('ADD_CAPTIONFILE', { + id, captionFile, nodeId, }); }); } } - -export async function loadCaptions({ commit, rootGetters }, params) { - const isAIFeatureEnabled = rootGetters['currentChannel/isAIFeatureEnabled']; - if(!isAIFeatureEnabled) return; - - // If a new file is uploaded, the contentnode_id will be string - if(typeof params.contentnode_id === 'string') { - params.contentnode_id = [params.contentnode_id] - } - const nodeIdsToLoad = []; - for (const nodeId of params.contentnode_id) { - const node = rootGetters['contentNode/getContentNode'](nodeId); - if (node && (node.kind === 'video' || node.kind === 'audio')) { - nodeIdsToLoad.push(nodeId); // already in vuex - } else if(!node) { - nodeIdsToLoad.push(nodeId); // Assume that its audio/video - } - } - - const captionFiles = await loadCaptionFiles(commit, { - contentnode_id: nodeIdsToLoad - }); - - // If there is no Caption File for this contentnode - // Don't request for the cues - if(captionFiles.length === 0) return; - // TODO: call loadCaptionCues -> to be done after - // I finish saving captionFiles in indexedDB When - // CTA is called. So I have captions saved in the backend. -} - - -export async function addCaptionFile({ commit }, { file_id, language, nodeId }) { - const captionFile = { - file_id: file_id, - language: language - } - return CaptionFile.add(captionFile).then(id => { - captionFile.id = id; - console.log(captionFile, nodeId); - commit('ADD_CAPTIONFILE', { - captionFile, - nodeId - }); - }) -} - -export async function loadCaptions({ commit, rootGetters }, params) { - const AI_FEATURE_FLAG = rootGetters['currentChannel/isAIFeatureEnabled'] - if(!AI_FEATURE_FLAG) return; - - const FILE_TYPE = rootGetters['contentNode/getContentNode'](params.contentnode_id).kind; - if(FILE_TYPE === 'video' || FILE_TYPE === 'audio') { - const captionFiles = await loadCaptionFiles(commit, params); - // If there is no Caption File for this contentnode - // Don't request for the cues - if(captionFiles.length === 0) return; - // TODO: call loadCaptionCues -> to be done after I finish saving captionFiles in indexedDB When CTA is called. So I have captions saved in the backend. - } -} - -export async function addCaptionFile({ commit }, { captionFile, nodeId }) { - commit('ADD_CAPTIONFILE', { captionFile, nodeId }); -} \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js index e30006eda2..2da7582223 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js @@ -17,10 +17,19 @@ export default { * ] */ captionFilesMap: [], - /* Caption Cues json to render in the frontend caption-editor - * to be defined + /* Caption Cues for a contentnode + * [ + * contentnode_id: { + * pk: { + * id: id + * starttime: starttime + * endtime: endtime + * text: text + * } + * }, + * ] */ - captionCuesMap: {}, + captionCuesMap: [], }), getters, mutations, diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js index 7174fec01b..b393c1a487 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js @@ -1,18 +1,17 @@ -import Vue from "vue"; -import { GENERATING } from 'shared/data/constants' +import Vue from 'vue'; +import { GENERATING } from 'shared/data/constants'; // import { applyMods } from 'shared/data/applyRemoteChanges'; /* Mutations for Caption File */ export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { - if(!captionFile && !nodeId) return; + if (!captionFile && !nodeId) return; // Check if there is Map for the current nodeId - if(!state.captionFilesMap[nodeId]) { + if (!state.captionFilesMap[nodeId]) { Vue.set(state.captionFilesMap, nodeId, {}); } // Check if the pk exists in the contentNode's object if (!state.captionFilesMap[nodeId][captionFile.id]) { - // If it doesn't exist, create an empty object for that pk Vue.set(state.captionFilesMap[nodeId], captionFile.id, {}); } @@ -20,7 +19,11 @@ export function ADD_CAPTIONFILE(state, { captionFile, nodeId }) { Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'id', captionFile.id); Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'file_id', captionFile.file_id); Vue.set(state.captionFilesMap[nodeId][captionFile.id], 'language', captionFile.language); - Vue.set(state.captionFilesMap[nodeId][captionFile.id], GENERATING, captionFile[GENERATING]) + Vue.set( + state.captionFilesMap[nodeId][captionFile.id], + GENERATING, + captionFile[GENERATING] ? captionFile[GENERATING] : false + ); } export function ADD_CAPTIONFILES(state, { captionFiles, nodeIds }) { @@ -33,24 +36,22 @@ export function ADD_CAPTIONFILES(state, { captionFiles, nodeIds }) { /* Mutations for Caption Cues */ export function ADD_CUE(state, cue) { // TODO: add some checks to Cue - Vue.set(state.captionCuesMap, cue.id, cue); } -export function ADD_CAPTIONCUES(state, { data } = []) { - if (Array.isArray(data)) { // Workaround to fix TypeError: data.forEach - data.forEach(cue => { - ADD_CUE(state, cue); - }) +export function ADD_CAPTIONCUES(state, { cues } = []) { + if (Array.isArray(cues)) { + cues.forEach(cue => { + ADD_CUE(state, { cue }); + }); } } export function UPDATE_CAPTIONFILE_FROM_INDEXEDDB(state, { id, ...mods }) { - if(!id) return; + if (!id) return; for (const nodeId in state.captionFilesMap) { if (state.captionFilesMap[nodeId][id]) { Vue.set(state.captionFilesMap[nodeId][id], GENERATING, mods[GENERATING]); - // updateCaptionCuesMaps(state, state.captionCuesMap[nodeId][id]); break; } } diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index d05f8ba109..5c129e42f7 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1034,7 +1034,7 @@ export const CaptionFile = new Resource({ .filter(f => !f[GENERATING]) .toArray(); }); - + return new Promise((resolve, reject) => { const subscription = observable.subscribe({ next(result) { diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 1d3e8f02c8..75e5cb0e85 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -64,7 +64,7 @@ class CaptionViewSet(ValuesViewset): def get_queryset(self): queryset = super().get_queryset() - contentnode_ids = self.request.GET.get("contentnode_id") + contentnode_ids = self.request.GET.get("contentnode__in") file_id = self.request.GET.get("file_id") language = self.request.GET.get("language") From c26b0e4cae00ce388ae030629082ffe8e2003c9a Mon Sep 17 00:00:00 2001 From: akash5100 Date: Thu, 31 Aug 2023 18:55:16 +0530 Subject: [PATCH 128/257] Update caption-related components and vuex --- .../automation/ai_models/__init__.py | 0 .../ai_models/automatic_speech_recognition.py | 28 ++++++++++++ .../automation/constants/__init__.py | 0 .../automation/constants/config.py | 6 +++ contentcuration/automation/settings.py | 0 contentcuration/automation/urls.py | 10 +++++ contentcuration/automation/utils/__init__.py | 0 .../utils/transcription_converter.py | 32 ++++++++++++++ contentcuration/automation/views.py | 28 +++++++++++- contentcuration/contentcuration/dev_urls.py | 3 ++ .../components/CaptionsTab/CaptionsTab.vue | 35 ++++++++++++--- .../channelEdit/vuex/caption/index.js | 4 +- .../channelEdit/vuex/caption/mutations.js | 28 +++++++++--- .../frontend/shared/data/resources.js | 9 +++- .../migrations/0143_captioncue_captionfile.py | 37 ---------------- contentcuration/contentcuration/tasks.py | 44 ++++++++++++++----- .../contentcuration/viewsets/caption.py | 8 ++-- 17 files changed, 200 insertions(+), 72 deletions(-) create mode 100644 contentcuration/automation/ai_models/__init__.py create mode 100644 contentcuration/automation/ai_models/automatic_speech_recognition.py create mode 100644 contentcuration/automation/constants/__init__.py create mode 100644 contentcuration/automation/constants/config.py create mode 100644 contentcuration/automation/settings.py create mode 100644 contentcuration/automation/urls.py create mode 100644 contentcuration/automation/utils/__init__.py create mode 100644 contentcuration/automation/utils/transcription_converter.py delete mode 100644 contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py diff --git a/contentcuration/automation/ai_models/__init__.py b/contentcuration/automation/ai_models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contentcuration/automation/ai_models/automatic_speech_recognition.py b/contentcuration/automation/ai_models/automatic_speech_recognition.py new file mode 100644 index 0000000000..7b25bbe8fd --- /dev/null +++ b/contentcuration/automation/ai_models/automatic_speech_recognition.py @@ -0,0 +1,28 @@ +from transformers import pipeline +from automation.constants.config import DEV_TRANSCRIPTION_MODEL, DEVICE + +class WhisperModel: + """ + A class for transcribing audio using a Whisper model with HuggingFace pipeline. + + Attributes: + max_token_length (int): The maximum number of tokens to generate. + model (str) : The ASR model name. + result (dict): The transcription result. + """ + def __init__(self, model_name=DEV_TRANSCRIPTION_MODEL) -> None: + # https://huggingface.co/docs/transformers/v4.29.1/en/generation_strategies#customize-text-generation + self.max_token_length = 448 + self.model = pipeline( + model=model_name, + chunk_length_s=10, + device=DEVICE, + return_timestamps=True, + ) + self.result = None + + def transcribe(self, media_url) -> list: + # UserWarning: Using `max_length`'s default (448) to control the generation length. This behaviour is deprecated and will be removed from the config in v5 of Transformers -- we recommend using `max_new_tokens` to control the maximum length of the generation. + token_length = self.max_token_length + self.result = self.model(media_url, max_new_tokens=token_length)["chunks"] + return self.result diff --git a/contentcuration/automation/constants/__init__.py b/contentcuration/automation/constants/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contentcuration/automation/constants/config.py b/contentcuration/automation/constants/config.py new file mode 100644 index 0000000000..394df04376 --- /dev/null +++ b/contentcuration/automation/constants/config.py @@ -0,0 +1,6 @@ +import torch + +DEVICE = "cuda:0" if torch.cuda.is_available() else "cpu" + +DEV_TRANSCRIPTION_MODEL = "openai/whisper-tiny" +TRANSCRIPTION_MODEL = "openai/whisper-tiny" diff --git a/contentcuration/automation/settings.py b/contentcuration/automation/settings.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contentcuration/automation/urls.py b/contentcuration/automation/urls.py new file mode 100644 index 0000000000..fdc6929b3a --- /dev/null +++ b/contentcuration/automation/urls.py @@ -0,0 +1,10 @@ +from automation.views import TranscriptionsViewSet +from django.urls import include, path +from rest_framework import routers + +automation_router = routers.DefaultRouter() +automation_router.register(r'transcribe', TranscriptionsViewSet, basename="transcribe") + +urlpatterns = [ + path("api/automation/", include(automation_router.urls), name='automation'), +] diff --git a/contentcuration/automation/utils/__init__.py b/contentcuration/automation/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contentcuration/automation/utils/transcription_converter.py b/contentcuration/automation/utils/transcription_converter.py new file mode 100644 index 0000000000..2e8372735e --- /dev/null +++ b/contentcuration/automation/utils/transcription_converter.py @@ -0,0 +1,32 @@ +import uuid + + +def whisper_converter(transcriptions: list, caption_file_id: str) -> list: + """ + Convert transcriptions from the Whisper model's output format to a contentcutation.models.CaptionCue format. + + Args: + transcriptions (list): A list of transcriptions produced by the Whisper model, where each + transcription is a dictionary with 'timestamp' and 'text' keys. + caption_file_id (str): Foreign key referencing the primary key of the CaptionFile model. + + Returns: + list: A list of cues, where each cue is a dictionary containing the following keys: + - 'text': The transcribed text. + - 'start_time': The start time of the transcription. + - 'end_time': The end time of the transcription. + """ + cues = [] + + for transcription in transcriptions: + start_time, end_time = transcription["timestamp"] + text = transcription["text"] + cue = { + "id": uuid.uuid4().hex, + "text": text, + "start_time": start_time, + "end_time": end_time, + "caption_file_id": caption_file_id, + } + cues.append(cue) + return cues diff --git a/contentcuration/automation/views.py b/contentcuration/automation/views.py index fd0e044955..6e2aeef737 100644 --- a/contentcuration/automation/views.py +++ b/contentcuration/automation/views.py @@ -1,3 +1,27 @@ -# from django.shortcuts import render +from rest_framework.viewsets import ViewSet +from rest_framework.response import Response -# Create your views here. +from contentcuration.models import CaptionFile, File +from automation.ai_models.automatic_speech_recognition import WhisperModel +from automation.utils.transcription_converter import whisper_converter + +class TranscriptionsViewSet(ViewSet): + def create(self, request): + caption_file_id = request.data['caption_file_id'] + caption_file = CaptionFile.objects.get(pk=caption_file_id) + file_id = caption_file.file_id + language = caption_file.language # TODO + + file_instance = File.objects.get(pk=file_id) + url = file_instance.file_on_disk.url + + whisper_model = WhisperModel() # <<< here we can set tiny or large-2 + transcriptions = whisper_model.transcribe(media_url=url) + cues = whisper_converter( + transcriptions=transcriptions, + caption_file_id=str(caption_file_id) + ) + + return Response({ + "cues": cues + }) diff --git a/contentcuration/contentcuration/dev_urls.py b/contentcuration/contentcuration/dev_urls.py index afbb7a83f8..8e54367747 100644 --- a/contentcuration/contentcuration/dev_urls.py +++ b/contentcuration/contentcuration/dev_urls.py @@ -1,5 +1,6 @@ import urllib.parse +from automation.urls import urlpatterns as automation_urlpatterns from django.conf import settings from django.contrib import admin from django.core.files.storage import default_storage @@ -76,6 +77,8 @@ def file_server(request, storage_path=None): re_path(r"^content/(?P.+)$", file_server), ] +urlpatterns += automation_urlpatterns + if getattr(settings, "DEBUG_PANEL_ACTIVE", False): import debug_toolbar diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue index 1eae27d2d6..8f184b3e71 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue @@ -51,6 +51,7 @@ import { notSupportedCaptionLanguages } from 'shared/leUtils/TranscriptionLanguages'; import LanguageDropdown from 'shared/views/LanguageDropdown'; import { CaptionFile, uuid4 } from 'shared/data/resources'; + import { GENERATING } from 'shared/data/constants'; export default { name: 'CaptionsTab', @@ -70,6 +71,9 @@ isGeneratingCaptions: false, }; }, + created() { + this.updateIsGeneratingCaptions(); + }, computed: { ...mapState('caption', ['captionFilesMap', 'captionCuesMap']), ...mapGetters('file', ['getContentNodeFiles']), @@ -79,11 +83,11 @@ }, }, methods: { - ...mapActions('caption', ['addCaptionFile', 'addCaptionCue']), + ...mapActions('caption', ['addCaptionFile']), logState() { console.log('nodeId ', this.nodeId); console.log(this.captionFilesMap[this.nodeId]); - console.log(this.captionCuesMap); + console.log(this.captionCuesMap[this.nodeId]); }, addCaption() { // TODO: select the `file` with longest duration as recommended by @bjester. @@ -96,8 +100,8 @@ const fileId = this.getContentNodeFiles(this.nodeId)[0].id; const language = this.selectedLanguage; if (!language && !fileId) return; - this.isGeneratingCaptions = true; + this.setLoadingFlag(true); this.addCaptionFile({ id: id, file_id: fileId, @@ -106,17 +110,34 @@ }); CaptionFile.waitForCaptionCueGeneration(id).then(generatingFlag => { + // known issue: the loading doesnt stop even loaded + // (means the flag is false) untill reloaded + this.setLoadingFlag(generatingFlag) this.selectedLanguage = null; - this.addCaptionCue({ caption_file_id: id }).then(cues => { - console.log(cues); - }); - this.isGeneratingCaptions = generatingFlag; + console.log('generating_flag: ', generatingFlag); }); }, fileName() { const name = String(this.getContentNodeFiles(this.nodeId)[0].original_filename); return name.split('.')[0]; }, + updateIsGeneratingCaptions() { + const captionFileIds = Object.keys(this.captionFilesMap[this.nodeId] || {}); + let isAnyGenerating = false; + for (const id of captionFileIds) { + if (this.captionFilesMap[this.nodeId][id][GENERATING] === true) { + isAnyGenerating = true; + break; // Exit loop if a generating flag is found + } + } + this.setLoadingFlag(isAnyGenerating); + // TODO: here set a UI like (generating "en" for this contentnode file) + // this adds more detail to what is generating + // by using the vuex[nodeId][id].language + }, + setLoadingFlag(value) { + this.isGeneratingCaptions = value; + }, }, $trs: { header: 'Add Captions', diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js index 2da7582223..8df9e2ad22 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/index.js @@ -10,8 +10,10 @@ export default { * [ * contentnode_id: { * pk: { + * id: pk * file_id: file_id * language: language + * __generating_captions: boolean * } * }, * ] @@ -20,7 +22,7 @@ export default { /* Caption Cues for a contentnode * [ * contentnode_id: { - * pk: { + * caption_file_id: { * id: id * starttime: starttime * endtime: endtime diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js index b393c1a487..daf8032f69 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/caption/mutations.js @@ -34,15 +34,31 @@ export function ADD_CAPTIONFILES(state, { captionFiles, nodeIds }) { } /* Mutations for Caption Cues */ -export function ADD_CUE(state, cue) { - // TODO: add some checks to Cue - Vue.set(state.captionCuesMap, cue.id, cue); +export function ADD_CUE(state, { cue, nodeId }) { + + console.log(cue, nodeId); + + if (!cue && !nodeId) return; + // Check if there is Map for the current nodeId + if (!state.captionCuesMap[nodeId]) { + Vue.set(state.captionCuesMap, nodeId, {}); + } + + // Check if the pk exists in the contentNode's object + if (!state.captionCuesMap[nodeId][cue.captiop_file_id]) { + Vue.set(state.captionCuesMap[nodeId], cue.captiop_file_id, {}); + } + + Vue.set(state.captionCuesMap[nodeId][cue.caption_file_id], 'id', cue.id); + Vue.set(state.captionCuesMap[nodeId][cue.caption_file_id], 'text', cue.text); + Vue.set(state.captionCuesMap[nodeId][cue.caption_file_id], 'starttime', cue.starttime); + Vue.set(state.captionCuesMap[nodeId][cue.caption_file_id], 'endtime', cue.endtime); } -export function ADD_CAPTIONCUES(state, { cues } = []) { - if (Array.isArray(cues)) { +export function ADD_CAPTIONCUES(state, { cues, nodeId }) { + if(Array.isArray(cues)) { cues.forEach(cue => { - ADD_CUE(state, { cue }); + ADD_CUE(state, { cue, nodeId }); }); } } diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 5c129e42f7..6428c815c8 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1056,10 +1056,15 @@ export const CaptionCues = new Resource({ tableName: TABLE_NAMES.CAPTION_CUES, urlName: 'captioncues', idField: 'id', - indexFields: ['text', 'starttime', 'endtime'], + indexFields: ['text', 'starttime', 'endtime', 'caption_file_id'], syncable: true, getChannelId: getChannelFromChannelScope, - + filterCuesByFileId(caption_file_id) { + return this.table + .where('id') + .equals(caption_file_id) + .toArray(); + }, collectionUrl(caption_file_id) { return this.getUrlFunction('list')(caption_file_id) }, diff --git a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py b/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py deleted file mode 100644 index b8176d6a02..0000000000 --- a/contentcuration/contentcuration/migrations/0143_captioncue_captionfile.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.2.14 on 2023-06-26 17:33 - -import contentcuration.models -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('contentcuration', '0142_add_task_signature'), - ] - - operations = [ - migrations.CreateModel( - name='CaptionFile', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('file_id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32)), - ('language', models.CharField(choices=[('en', 'english'), ('zh', 'chinese'), ('de', 'german'), ('es', 'spanish'), ('ru', 'russian'), ('ko', 'korean'), ('fr', 'french'), ('ja', 'japanese'), ('pt', 'portuguese'), ('tr', 'turkish'), ('pl', 'polish'), ('ca', 'catalan'), ('nl', 'dutch'), ('ar', 'arabic'), ('sv', 'swedish'), ('it', 'italian'), ('id', 'indonesian'), ('hi', 'hindi'), ('fi', 'finnish'), ('vi', 'vietnamese'), ('he', 'hebrew'), ('uk', 'ukrainian'), ('el', 'greek'), ('ms', 'malay'), ('cs', 'czech'), ('ro', 'romanian'), ('da', 'danish'), ('hu', 'hungarian'), ('ta', 'tamil'), ('no', 'norwegian'), ('th', 'thai'), ('ur', 'urdu'), ('hr', 'croatian'), ('bg', 'bulgarian'), ('lt', 'lithuanian'), ('la', 'latin'), ('mi', 'maori'), ('ml', 'malayalam'), ('cy', 'welsh'), ('sk', 'slovak'), ('te', 'telugu'), ('fa', 'persian'), ('lv', 'latvian'), ('bn', 'bengali'), ('sr', 'serbian'), ('az', 'azerbaijani'), ('sl', 'slovenian'), ('kn', 'kannada'), ('et', 'estonian'), ('mk', 'macedonian'), ('br', 'breton'), ('eu', 'basque'), ('is', 'icelandic'), ('hy', 'armenian'), ('ne', 'nepali'), ('mn', 'mongolian'), ('bs', 'bosnian'), ('kk', 'kazakh'), ('sq', 'albanian'), ('sw', 'swahili'), ('gl', 'galician'), ('mr', 'marathi'), ('pa', 'punjabi'), ('si', 'sinhala'), ('km', 'khmer'), ('sn', 'shona'), ('yo', 'yoruba'), ('so', 'somali'), ('af', 'afrikaans'), ('oc', 'occitan'), ('ka', 'georgian'), ('be', 'belarusian'), ('tg', 'tajik'), ('sd', 'sindhi'), ('gu', 'gujarati'), ('am', 'amharic'), ('yi', 'yiddish'), ('lo', 'lao'), ('uz', 'uzbek'), ('fo', 'faroese'), ('ht', 'haitian creole'), ('ps', 'pashto'), ('tk', 'turkmen'), ('nn', 'nynorsk'), ('mt', 'maltese'), ('sa', 'sanskrit'), ('lb', 'luxembourgish'), ('my', 'myanmar'), ('bo', 'tibetan'), ('tl', 'tagalog'), ('mg', 'malagasy'), ('as', 'assamese'), ('tt', 'tatar'), ('haw', 'hawaiian'), ('ln', 'lingala'), ('ha', 'hausa'), ('ba', 'bashkir'), ('jw', 'javanese'), ('su', 'sundanese')], max_length=3)), - ], - options={ - 'unique_together': {('file_id', 'language')}, - }, - ), - migrations.CreateModel( - name='CaptionCue', - fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('text', models.TextField()), - ('starttime', models.FloatField()), - ('endtime', models.FloatField()), - ('caption_file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='caption_cue', to='contentcuration.captionfile')), - ], - ), - ] diff --git a/contentcuration/contentcuration/tasks.py b/contentcuration/contentcuration/tasks.py index b2079a494b..3d1fea03e7 100644 --- a/contentcuration/contentcuration/tasks.py +++ b/contentcuration/contentcuration/tasks.py @@ -14,6 +14,7 @@ from django.core.mail import EmailMessage from django.template.loader import render_to_string from django.utils.translation import override +from django.urls import reverse from contentcuration.celery import app from contentcuration.models import Change @@ -144,27 +145,46 @@ def generatecaptioncues_task(caption_file_id, channel_id, user_id): import uuid from contentcuration.viewsets.caption import CaptionCueSerializer - from contentcuration.viewsets.sync.utils import generate_update_event - from contentcuration.viewsets.sync.constants import CAPTION_FILE + from contentcuration.viewsets.sync.constants import CAPTION_FILE, CAPTION_CUES + from contentcuration.viewsets.sync.utils import generate_update_event, generate_create_event + + # if the response is success, we send cues to frontend with change event + # by creating changes set the generating flag to false. + print(reverse("automation:transcribe")) - time.sleep(10) # AI model start generating cue = { "id": uuid.uuid4().hex, - "text":"hello guys", + "text": "hello", "starttime": 0, "endtime": 5, - "caption_file_id": caption_file_id + "caption_file_id": caption_file_id, } serializer = CaptionCueSerializer(data=cue) if serializer.is_valid(): - instance = serializer.save() - Change.create_change(generate_update_event( - caption_file_id, - CAPTION_FILE, - {"__generating_captions": False}, - channel_id=channel_id, + serializer.save() + Change.create_change(generate_create_event( + cue["id"], + CAPTION_CUES, + { + "id": cue["id"], + "text": cue["text"], + "starttime": cue["starttime"], + "endtime": cue["endtime"], + "caption_file_id": cue["caption_file_id"], + }, + channel_id=channel_id, ), applied=True, created_by_id=user_id) + + time.sleep(10) + + Change.create_change(generate_update_event( + caption_file_id, + CAPTION_FILE, + {"__generating_captions": False}, + channel_id=channel_id, + ), applied=True, created_by_id=user_id) + else: - print(serializer.errors) + print(serializer.errors) \ No newline at end of file diff --git a/contentcuration/contentcuration/viewsets/caption.py b/contentcuration/contentcuration/viewsets/caption.py index 75e5cb0e85..3cfa5bc0d9 100644 --- a/contentcuration/contentcuration/viewsets/caption.py +++ b/contentcuration/contentcuration/viewsets/caption.py @@ -89,21 +89,19 @@ def perform_create(self, serializer, change=None): generate_update_event( instance.pk, CAPTION_FILE, - { - "__generating_captions": True, - }, + { "__generating_captions": True, }, channel_id=change['channel_id'] ), applied=True, created_by_id=self.request.user.id ) # enqueue task of generating captions for the saved CaptionFile instance try: - # Also sets the generating flag to false <<< Generating Completeted + # Also sets the generating flag to false <<< Generating Completed generatecaptioncues_task.enqueue( self.request.user, caption_file_id=instance.pk, channel_id=change['channel_id'], - user_id=self.request.user.id + user_id=self.request.user.id, ) except Exception as e: From 8c4b7718f012de5669ae995252c587ae8c871844 Mon Sep 17 00:00:00 2001 From: akash5100 Date: Mon, 11 Sep 2023 12:25:49 +0530 Subject: [PATCH 129/257] Enhance settings and Vue modal --- .../ai_models/automatic_speech_recognition.py | 11 +- .../automation/constants/__init__.py | 0 .../automation/constants/config.py | 6 - contentcuration/automation/settings.py | 23 ++++ contentcuration/automation/views.py | 6 +- .../components/CaptionsTab/CaptionsTab.vue | 117 ++++++++---------- .../channelEdit/vuex/caption/getters.js | 16 ++- .../channelEdit/vuex/caption/mutations.js | 2 +- 8 files changed, 100 insertions(+), 81 deletions(-) delete mode 100644 contentcuration/automation/constants/__init__.py delete mode 100644 contentcuration/automation/constants/config.py diff --git a/contentcuration/automation/ai_models/automatic_speech_recognition.py b/contentcuration/automation/ai_models/automatic_speech_recognition.py index 7b25bbe8fd..9fd2c9f618 100644 --- a/contentcuration/automation/ai_models/automatic_speech_recognition.py +++ b/contentcuration/automation/ai_models/automatic_speech_recognition.py @@ -1,7 +1,8 @@ +from automation.settings import CHUNK_LENGTH, DEV_TRANSCRIPTION_MODEL, DEVICE, MAX_TOKEN_LENGTH from transformers import pipeline -from automation.constants.config import DEV_TRANSCRIPTION_MODEL, DEVICE -class WhisperModel: + +class WhisperTranscriber: """ A class for transcribing audio using a Whisper model with HuggingFace pipeline. @@ -11,18 +12,16 @@ class WhisperModel: result (dict): The transcription result. """ def __init__(self, model_name=DEV_TRANSCRIPTION_MODEL) -> None: - # https://huggingface.co/docs/transformers/v4.29.1/en/generation_strategies#customize-text-generation - self.max_token_length = 448 + self.max_token_length = MAX_TOKEN_LENGTH self.model = pipeline( model=model_name, - chunk_length_s=10, + chunk_length_s=CHUNK_LENGTH, device=DEVICE, return_timestamps=True, ) self.result = None def transcribe(self, media_url) -> list: - # UserWarning: Using `max_length`'s default (448) to control the generation length. This behaviour is deprecated and will be removed from the config in v5 of Transformers -- we recommend using `max_new_tokens` to control the maximum length of the generation. token_length = self.max_token_length self.result = self.model(media_url, max_new_tokens=token_length)["chunks"] return self.result diff --git a/contentcuration/automation/constants/__init__.py b/contentcuration/automation/constants/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/contentcuration/automation/constants/config.py b/contentcuration/automation/constants/config.py deleted file mode 100644 index 394df04376..0000000000 --- a/contentcuration/automation/constants/config.py +++ /dev/null @@ -1,6 +0,0 @@ -import torch - -DEVICE = "cuda:0" if torch.cuda.is_available() else "cpu" - -DEV_TRANSCRIPTION_MODEL = "openai/whisper-tiny" -TRANSCRIPTION_MODEL = "openai/whisper-tiny" diff --git a/contentcuration/automation/settings.py b/contentcuration/automation/settings.py index e69de29bb2..89fc680ee8 100644 --- a/contentcuration/automation/settings.py +++ b/contentcuration/automation/settings.py @@ -0,0 +1,23 @@ +from enum import Enum + +from torch.cuda import is_available as is_gpu_available + +DEVICE = "cuda:0" if is_gpu_available() else "cpu" + + +# [TRANSCRIPTION GENERATION] +class WhisperModel(Enum): + TINY = "openai/whisper-tiny" + BASE = "openai/whisper-base" + SMALL = "openai/whisper-small" + MEDIUM = "openai/whisper-medium" + LARGE = "openai/whisper-large" + LARGEV2 = "openai/whisper-large-v2" + + +DEV_TRANSCRIPTION_MODEL = WhisperModel.TINY +TRANSCRIPTION_MODEL = WhisperModel.TINY + +# https://huggingface.co/docs/transformers/v4.29.1/en/generation_strategies#customize-text-generation +MAX_TOKEN_LENGTH = 448 +CHUNK_LENGTH = 10 diff --git a/contentcuration/automation/views.py b/contentcuration/automation/views.py index 6e2aeef737..37fd1680b6 100644 --- a/contentcuration/automation/views.py +++ b/contentcuration/automation/views.py @@ -2,7 +2,7 @@ from rest_framework.response import Response from contentcuration.models import CaptionFile, File -from automation.ai_models.automatic_speech_recognition import WhisperModel +from automation.ai_models.automatic_speech_recognition import WhisperTranscriber from automation.utils.transcription_converter import whisper_converter class TranscriptionsViewSet(ViewSet): @@ -15,8 +15,8 @@ def create(self, request): file_instance = File.objects.get(pk=file_id) url = file_instance.file_on_disk.url - whisper_model = WhisperModel() # <<< here we can set tiny or large-2 - transcriptions = whisper_model.transcribe(media_url=url) + whisper = WhisperTranscriber() + transcriptions = whisper.transcribe(media_url=url) cues = whisper_converter( transcriptions=transcriptions, caption_file_id=str(caption_file_id) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue index 8f184b3e71..b2954bf941 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/CaptionsTab/CaptionsTab.vue @@ -1,13 +1,22 @@