Repository: openfun/marsha

Issue:
Display subtitle as transcript if none uploaded
## Feature Request

**Is your feature request related to a problem or unsupported use case? Please describe.**

If subtitles are uploaded but no transcript, we would like to use the subtitles as transcripts.


Discussion:
@lunika then should we add a `show_transcript` flag to control the presence of a transcript? or should the transcript always be shown when there is a subtitle (for accessibility reason for example)?

Resolving patch:
diff --git a/src/backend/marsha/core/migrations/0017_auto_20200221_1433.py b/src/backend/marsha/core/migrations/0017_auto_20200221_1433.py
new file mode 100644
--- /dev/null
+++ b/src/backend/marsha/core/migrations/0017_auto_20200221_1433.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.0.3 on 2020-02-21 14:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("core", "0016_auto_20200402_2047"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="video",
+            name="should_use_subtitle_as_transcript",
+            field=models.BooleanField(
+                default=False,
+                help_text="When there is at least one subtitle but no transcript this flag allows to use subtitles as transcripts.",
+                verbose_name="use subtitle as transcript",
+            ),
+        ),
+    ]
diff --git a/src/backend/marsha/core/models/video.py b/src/backend/marsha/core/models/video.py
--- a/src/backend/marsha/core/models/video.py
+++ b/src/backend/marsha/core/models/video.py
@@ -15,6 +15,15 @@ class Video(BaseFile):
 
     RESOURCE_NAME = "videos"
 
+    should_use_subtitle_as_transcript = models.BooleanField(
+        default=False,
+        verbose_name=_("use subtitle as transcript"),
+        help_text=_(
+            "When there is at least one subtitle but no transcript this flag allows "
+            "to use subtitles as transcripts."
+        ),
+    )
+
     class Meta:
         """Options for the ``Video`` model."""
 
diff --git a/src/backend/marsha/core/serializers.py b/src/backend/marsha/core/serializers.py
--- a/src/backend/marsha/core/serializers.py
+++ b/src/backend/marsha/core/serializers.py
@@ -281,6 +281,8 @@ class Meta:  # noqa
             "upload_state",
             "urls",
             "show_download",
+            "should_use_subtitle_as_transcript",
+            "has_transcript",
         )
         read_only_fields = (
             "id",
@@ -288,6 +290,7 @@ class Meta:  # noqa
             "is_ready_to_show",
             "upload_state",
             "urls",
+            "has_transcript",
         )
 
     active_stamp = TimestampField(
@@ -299,6 +302,23 @@ class Meta:  # noqa
     thumbnail = ThumbnailSerializer(read_only=True, allow_null=True)
     urls = serializers.SerializerMethodField()
     is_ready_to_show = serializers.BooleanField(read_only=True)
+    has_transcript = serializers.SerializerMethodField()
+
+    def get_has_transcript(self, obj):
+        """Compute if should_use_subtitle_as_transcript behavior is disabled.
+
+        Parameters
+        ----------
+        obj : Type[models.Video]
+            The video that we want to serialize
+
+        Returns
+        -------
+        Boolean
+            If there is at least one transcript ready to be shown the method will return True.
+            Returns False otherwise.
+        """
+        return obj.timedtexttracks.filter(mode="ts", uploaded_on__isnull=False).exists()
 
     def get_urls(self, obj):
         """Urls of the video for each type of encoding.


Test patch:
diff --git a/src/backend/marsha/core/tests/test_api_video.py b/src/backend/marsha/core/tests/test_api_video.py
--- a/src/backend/marsha/core/tests/test_api_video.py
+++ b/src/backend/marsha/core/tests/test_api_video.py
@@ -193,6 +193,8 @@ def test_api_video_read_detail_token_user(self):
                         "previews/1533686400_100.jpg"
                     ),
                 },
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": False,
             },
         )
 
@@ -255,6 +257,8 @@ def test_api_video_read_detail_token_user_no_active_stamp(self):
                 "timed_text_tracks": [],
                 "thumbnail": None,
                 "urls": None,
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": False,
             },
         )
 
@@ -289,6 +293,8 @@ def test_api_video_read_detail_token_user_not_sucessfully_uploaded(self):
                 "timed_text_tracks": [],
                 "thumbnail": None,
                 "urls": None,
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": False,
             },
         )
 
diff --git a/src/backend/marsha/core/tests/test_views_lti_cache.py b/src/backend/marsha/core/tests/test_views_lti_cache.py
--- a/src/backend/marsha/core/tests/test_views_lti_cache.py
+++ b/src/backend/marsha/core/tests/test_views_lti_cache.py
@@ -55,7 +55,7 @@ def test_views_lti_cache_student(self, mock_get_consumer_site, mock_verify):
             "user_id": "111",
         }
 
-        with self.assertNumQueries(3):
+        with self.assertNumQueries(4):
             elapsed, resource_origin = self._post_lti_request(url, data)
         self.assertEqual(resource_origin["id"], str(video1.id))
         self.assertTrue(elapsed < 0.1)
@@ -69,7 +69,7 @@ def test_views_lti_cache_student(self, mock_get_consumer_site, mock_verify):
 
         # The cache should not be hit on first call if we change the playlist id
         data["context_id"] = "other_playlist"
-        with self.assertNumQueries(3):
+        with self.assertNumQueries(4):
             elapsed, resource = self._post_lti_request(url, data)
         self.assertEqual(resource, resource_origin)
         self.assertTrue(elapsed < 0.1)
@@ -81,7 +81,7 @@ def test_views_lti_cache_student(self, mock_get_consumer_site, mock_verify):
 
         # The cache should not be hit on first call if we change the domain
         mock_get_consumer_site.return_value = ConsumerSiteFactory()
-        with self.assertNumQueries(3):
+        with self.assertNumQueries(4):
             elapsed, resource = self._post_lti_request(url, data)
         self.assertEqual(resource, resource_origin)
         self.assertTrue(elapsed < 0.1)
@@ -95,7 +95,7 @@ def test_views_lti_cache_student(self, mock_get_consumer_site, mock_verify):
 
         # The cache should not be hit on first call if we change the resource id
         url = "/lti/videos/{!s}".format(video2.pk)
-        with self.assertNumQueries(3):
+        with self.assertNumQueries(4):
             elapsed, resource_video2 = self._post_lti_request(url, data)
         self.assertEqual(resource_video2["id"], str(video2.id))
         self.assertTrue(elapsed < 0.1)
@@ -131,14 +131,14 @@ def test_views_lti_cache_instructor(self, mock_get_consumer_site, mock_verify):
             "user_id": "111",
         }
 
-        with self.assertNumQueries(3):
+        with self.assertNumQueries(4):
             elapsed, resource_origin = self._post_lti_request(url, data)
         self.assertEqual(resource_origin["id"], str(video.id))
         self.assertTrue(elapsed < 0.1)
 
         # Calling the same resource a second time with the same LTI parameters
         # should not hit the cache
-        with self.assertNumQueries(3):
+        with self.assertNumQueries(4):
             elapsed, resource = self._post_lti_request(url, data)
         self.assertEqual(resource, resource_origin)
         self.assertTrue(elapsed < 0.1)
diff --git a/src/backend/marsha/core/tests/test_views_lti_development.py b/src/backend/marsha/core/tests/test_views_lti_development.py
--- a/src/backend/marsha/core/tests/test_views_lti_development.py
+++ b/src/backend/marsha/core/tests/test_views_lti_development.py
@@ -139,6 +139,8 @@ def test_views_lti_development_post_bypass_lti_instructor(self):
                 "thumbnail": None,
                 "title": video.title,
                 "urls": None,
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": False,
             },
         )
         self.assertEqual(context.get("modelName"), "videos")
@@ -193,6 +195,8 @@ def test_views_lti_development_post_bypass_lti_instructor_no_video(self):
                 "thumbnail": None,
                 "title": video.title,
                 "urls": None,
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": False,
             },
         )
         self.assertEqual(context.get("modelName"), "videos")
diff --git a/src/backend/marsha/core/tests/test_views_lti_video.py b/src/backend/marsha/core/tests/test_views_lti_video.py
--- a/src/backend/marsha/core/tests/test_views_lti_video.py
+++ b/src/backend/marsha/core/tests/test_views_lti_video.py
@@ -12,7 +12,7 @@
 from pylti.common import LTIException
 from rest_framework_simplejwt.tokens import AccessToken
 
-from ..defaults import STATE_CHOICES
+from ..defaults import PENDING, READY, STATE_CHOICES
 from ..factories import (
     ConsumerSiteLTIPassportFactory,
     TimedTextTrackFactory,
@@ -28,6 +28,8 @@
 class VideoLTIViewTestCase(TestCase):
     """Test the video view in the ``core`` app of the Marsha project."""
 
+    maxDiff = None
+
     @mock.patch.object(LTI, "verify")
     @mock.patch.object(LTI, "get_consumer_site")
     @override_settings(SENTRY_DSN="https://sentry.dsn")
@@ -91,6 +93,8 @@ def test_views_lti_video_post_instructor(self, mock_get_consumer_site, mock_veri
                 "thumbnail": None,
                 "title": video.title,
                 "urls": None,
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": False,
             },
         )
         self.assertEqual(context.get("modelName"), "videos")
@@ -188,6 +192,8 @@ def test_views_lti_video_post_administrator(
                 "thumbnail": None,
                 "title": video.title,
                 "urls": None,
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": False,
             },
         )
         self.assertEqual(context.get("modelName"), "videos")
@@ -288,6 +294,8 @@ def test_views_lti_video_read_other_playlist(
                     "previews": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                     "previews/1569309880_100.jpg",
                 },
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": False,
             },
         )
         self.assertEqual(context.get("modelName"), "videos")
@@ -397,6 +405,8 @@ def test_views_lti_video_post_student_with_video(
                     "previews": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                     "previews/1569309880_100.jpg",
                 },
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": False,
             },
         )
         self.assertEqual(context.get("modelName"), "videos")
@@ -574,3 +584,131 @@ def test_views_lti_video_static_base_url(self, mock_get_consumer_site, mock_veri
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "<html>")
         self.assertContains(response, '<meta name="public-path" value="/static/js/" />')
+
+    @mock.patch.object(LTI, "verify")
+    @mock.patch.object(LTI, "get_consumer_site")
+    def test_views_lti_video_has_transcript(self, mock_get_consumer_site, mock_verify):
+        """Compute has_transcript when a transcript is uploaded."""
+        passport = ConsumerSiteLTIPassportFactory()
+        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
+        # Create a TimedTextTrack associated with the video to trigger the error
+        transcript = TimedTextTrackFactory(
+            video=video,
+            mode="ts",
+            upload_state=READY,
+            uploaded_on="2019-09-24 07:24:40+00",
+        )
+
+        data = {
+            "resource_link_id": video.lti_id,
+            "context_id": video.playlist.lti_id,
+            "roles": "instructor",
+            "oauth_consumer_key": passport.oauth_consumer_key,
+            "user_id": "56255f3807599c377bf0e5bf072359fd",
+            "launch_presentation_locale": "fr",
+        }
+
+        mock_get_consumer_site.return_value = passport.consumer_site
+        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "<html>")
+        content = response.content.decode("utf-8")
+
+        match = re.search(
+            '<div id="marsha-frontend-data" data-context="(.*)">', content
+        )
+
+        context = json.loads(unescape(match.group(1)))
+
+        self.assertEqual(
+            context.get("resource"),
+            {
+                "active_stamp": None,
+                "is_ready_to_show": False,
+                "show_download": True,
+                "description": video.description,
+                "id": str(video.id),
+                "upload_state": "pending",
+                "timed_text_tracks": [
+                    {
+                        "active_stamp": "1569309880",
+                        "id": str(transcript.id),
+                        "is_ready_to_show": True,
+                        "mode": "ts",
+                        "language": transcript.language,
+                        "upload_state": "ready",
+                        "url": "https://abc.cloudfront.net/{!s}/timedtext/"
+                        "1569309880_{!s}_ts.vtt".format(video.id, transcript.language),
+                        "video": str(video.id),
+                    }
+                ],
+                "thumbnail": None,
+                "title": video.title,
+                "urls": None,
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": True,
+            },
+        )
+
+    @mock.patch.object(LTI, "verify")
+    @mock.patch.object(LTI, "get_consumer_site")
+    def test_views_lti_video_has_transcript_false(
+        self, mock_get_consumer_site, mock_verify
+    ):
+        """Compute has_transcript when a transcript is uploaded and not ready to be shown."""
+        passport = ConsumerSiteLTIPassportFactory()
+        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
+        # Create a TimedTextTrack associated with the video to trigger the error
+        transcript = TimedTextTrackFactory(
+            video=video, mode="ts", upload_state=PENDING,
+        )
+
+        data = {
+            "resource_link_id": video.lti_id,
+            "context_id": video.playlist.lti_id,
+            "roles": "instructor",
+            "oauth_consumer_key": passport.oauth_consumer_key,
+            "user_id": "56255f3807599c377bf0e5bf072359fd",
+            "launch_presentation_locale": "fr",
+        }
+
+        mock_get_consumer_site.return_value = passport.consumer_site
+        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "<html>")
+        content = response.content.decode("utf-8")
+
+        match = re.search(
+            '<div id="marsha-frontend-data" data-context="(.*)">', content
+        )
+
+        context = json.loads(unescape(match.group(1)))
+
+        self.assertEqual(
+            context.get("resource"),
+            {
+                "active_stamp": None,
+                "is_ready_to_show": False,
+                "show_download": True,
+                "description": video.description,
+                "id": str(video.id),
+                "upload_state": "pending",
+                "timed_text_tracks": [
+                    {
+                        "active_stamp": None,
+                        "id": str(transcript.id),
+                        "is_ready_to_show": False,
+                        "mode": "ts",
+                        "language": transcript.language,
+                        "upload_state": "pending",
+                        "url": None,
+                        "video": str(video.id),
+                    }
+                ],
+                "thumbnail": None,
+                "title": video.title,
+                "urls": None,
+                "should_use_subtitle_as_transcript": False,
+                "has_transcript": False,
+            },
+        )