Repository: openfun/marsha

Issue:
Allow instructors to publish their video publicly

## Feature Request

**Is your feature request related to a problem or unsupported use case? Please describe.**
We want instructors to be able to publish their video publicly.
At the moment, the flag is only accessible via the Django admin interface.

**Describe the solution you'd like**
expose the "is_public" field to the video API endpoint, allowing only instructors to update its value.



Discussion:
Only instructors can update resources for now. IMO we don't need to add  more check. Do you have an other idea in mind by specifying `allowing only instructors to update its value.` ?
no, just to write a test that makes sure of it but it's surely covered by the rule you mention :+1: 

Resolving patch:
diff --git a/src/backend/marsha/core/serializers/video.py b/src/backend/marsha/core/serializers/video.py
--- a/src/backend/marsha/core/serializers/video.py
+++ b/src/backend/marsha/core/serializers/video.py
@@ -548,6 +548,7 @@ class Meta:  # noqa
             "active_stamp",
             "description",
             "id",
+            "is_public",
             "is_ready_to_show",
             "is_scheduled",
             "timed_text_tracks",


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
@@ -226,6 +226,7 @@ def test_api_video_read_detail_token_user(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": "1533686400",
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -337,6 +338,7 @@ def test_api_video_read_detail_token_user_no_active_stamp(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
@@ -389,6 +391,7 @@ def test_api_video_read_detail_token_user_not_sucessfully_uploaded(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
@@ -528,6 +531,7 @@ def test_api_video_read_detail_by_organization_admin(self):
                 "description": video.description,
                 "has_transcript": False,
                 "id": str(video.id),
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "live_info": {},
@@ -602,6 +606,7 @@ def test_api_video_read_detail_by_playlist_admin(self):
                 "description": video.description,
                 "has_transcript": False,
                 "id": str(video.id),
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "live_info": {},
@@ -726,6 +731,7 @@ def test_api_video_read_list_user_with_playlist_access(self):
                         "description": video.description,
                         "has_transcript": False,
                         "id": str(video.id),
+                        "is_public": False,
                         "is_ready_to_show": False,
                         "is_scheduled": False,
                         "live_info": {},
@@ -803,6 +809,7 @@ def test_api_video_read_list_user_with_organization_access(self):
                         "description": video_1.description,
                         "has_transcript": False,
                         "id": str(video_1.id),
+                        "is_public": False,
                         "is_ready_to_show": False,
                         "is_scheduled": False,
                         "live_info": {},
@@ -828,6 +835,7 @@ def test_api_video_read_list_user_with_organization_access(self):
                         "description": video_2.description,
                         "has_transcript": False,
                         "id": str(video_2.id),
+                        "is_public": False,
                         "is_ready_to_show": False,
                         "is_scheduled": False,
                         "live_info": {},
@@ -919,6 +927,7 @@ def test_api_video_read_list_by_playlist_user_with_playlist_access(self):
                         "description": video.description,
                         "has_transcript": False,
                         "id": str(video.id),
+                        "is_public": False,
                         "is_ready_to_show": False,
                         "is_scheduled": False,
                         "live_info": {},
@@ -985,6 +994,7 @@ def test_api_video_read_list_by_playlist_user_with_org_access(self):
                         "description": video.description,
                         "has_transcript": False,
                         "id": str(video.id),
+                        "is_public": False,
                         "is_ready_to_show": False,
                         "is_scheduled": False,
                         "live_info": {},
@@ -1080,6 +1090,7 @@ def test_api_video_read_list_by_org_user_with_playlist_access(self):
                         "description": video.description,
                         "has_transcript": False,
                         "id": str(video.id),
+                        "is_public": False,
                         "is_ready_to_show": False,
                         "is_scheduled": False,
                         "live_info": {},
@@ -1145,6 +1156,7 @@ def test_api_video_read_list_by_org_user_with_org_access(self):
                         "description": video_1.description,
                         "has_transcript": False,
                         "id": str(video_1.id),
+                        "is_public": False,
                         "is_ready_to_show": False,
                         "is_scheduled": False,
                         "live_info": {},
@@ -1170,6 +1182,7 @@ def test_api_video_read_list_by_org_user_with_org_access(self):
                         "description": video_2.description,
                         "has_transcript": False,
                         "id": str(video_2.id),
+                        "is_public": False,
                         "is_ready_to_show": False,
                         "is_scheduled": False,
                         "live_info": {},
@@ -1284,6 +1297,20 @@ def test_api_video_create_token_user_playlist_preexists(self):
         self.assertEqual(response.status_code, 401)
         self.assertFalse(models.Video.objects.exists())
 
+    def test_api_video_create_student(self):
+        """Student users should not be able to create videos."""
+        video = factories.VideoFactory()
+        jwt_token = AccessToken()
+        jwt_token.payload["resource_id"] = str(video.id)
+        jwt_token.payload["roles"] = ["student"]
+        jwt_token.payload["permissions"] = {"can_update": False}
+        response = self.client.post(
+            "/api/videos/",
+            HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
+        )
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(models.Video.objects.count(), 1)
+
     def test_api_video_create_staff_or_user(self):
         """Users authenticated via a session should not be able to create videos."""
         for user in [factories.UserFactory(), factories.UserFactory(is_staff=True)]:
@@ -1333,6 +1360,7 @@ def test_api_video_create_by_playlist_admin(self):
                 "description": "",
                 "has_transcript": False,
                 "id": str(models.Video.objects.get().id),
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "live_info": {},
@@ -1493,6 +1521,7 @@ def test_api_video_create_by_organization_admin(self):
                 "description": "",
                 "has_transcript": False,
                 "id": str(models.Video.objects.get().id),
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "live_info": {},
@@ -1559,6 +1588,7 @@ def test_api_video_create_with_scheduled_date_gets_ignored(self):
                 "description": "",
                 "has_transcript": False,
                 "id": str(models.Video.objects.get().id),
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "live_info": {},
@@ -1630,6 +1660,28 @@ def test_api_video_update_detail_anonymous(self):
         video.refresh_from_db()
         self.assertEqual(video.title, "my title")
 
+    def test_api_video_update_detail_student(self):
+        """Student users should not be allowed to update a video through the API."""
+        video = factories.VideoFactory(title="my title")
+        jwt_token = AccessToken()
+        jwt_token.payload["resource_id"] = str(video.id)
+        jwt_token.payload["roles"] = ["student"]
+
+        data = {"title": "my new title"}
+        response = self.client.put(
+            f"/api/videos/{video.id}/",
+            data,
+            content_type="application/json",
+            HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
+        )
+        self.assertEqual(response.status_code, 403)
+        content = json.loads(response.content)
+        self.assertEqual(
+            content, {"detail": "You do not have permission to perform this action."}
+        )
+        video.refresh_from_db()
+        self.assertEqual(video.title, "my title")
+
     def test_api_video_update_detail_token_user_title(self):
         """Token users should be able to update the title of their video through the API."""
         video = factories.VideoFactory(title="my title")
@@ -1886,6 +1938,41 @@ def test_api_video_instructor_update_video_in_read_only(self):
         )
         self.assertEqual(response.status_code, 403)
 
+    def test_api_video_patch_video_anonymous(self):
+        """Anonymous users should not be allowed to patch a video through the API."""
+        video = factories.VideoFactory(title="my title")
+        data = {"title": "my new title"}
+        response = self.client.patch(
+            f"/api/videos/{video.id}/",
+            data,
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, 401)
+        video.refresh_from_db()
+        self.assertEqual(video.title, "my title")
+
+    def test_api_video_patch_video_student(self):
+        """Student users should not be allowed to patch a video through the API."""
+        video = factories.VideoFactory(title="my title")
+        jwt_token = AccessToken()
+        jwt_token.payload["resource_id"] = str(video.id)
+        jwt_token.payload["roles"] = ["student"]
+
+        data = {"title": "my new title"}
+        response = self.client.patch(
+            f"/api/videos/{video.id}/",
+            data,
+            content_type="application/json",
+            HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
+        )
+        self.assertEqual(response.status_code, 403)
+        content = json.loads(response.content)
+        self.assertEqual(
+            content, {"detail": "You do not have permission to perform this action."}
+        )
+        video.refresh_from_db()
+        self.assertEqual(video.title, "my title")
+
     def test_api_video_instructor_patch_video_in_read_only(self):
         """An instructor with read_only set to true should not be able to patch the video."""
         video = factories.VideoFactory()
@@ -1996,6 +2083,25 @@ def test_api_video_patch_detail_token_user_description(self):
         video.refresh_from_db()
         self.assertEqual(video.description, "my new description")
 
+    def test_api_video_patch_detail_token_user_is_public(self):
+        """Instructors and administrators should be able to
+        patch the public flag of their video through the API."""
+        video = factories.VideoFactory(is_public=False)
+        jwt_token = AccessToken()
+        jwt_token.payload["resource_id"] = str(video.id)
+        jwt_token.payload["roles"] = [random.choice(["instructor", "administrator"])]
+        jwt_token.payload["permissions"] = {"can_update": True}
+        data = {"is_public": True}
+        response = self.client.patch(
+            f"/api/videos/{video.id}/",
+            data,
+            HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, 200)
+        video.refresh_from_db()
+        self.assertTrue(video.is_public)
+
     def test_api_video_patch_by_organization_instructor(self):
         """Organization instructors cannot patch videos on the API."""
         user = factories.UserFactory()
@@ -2472,6 +2578,24 @@ def test_api_video_delete_detail_token_user(self):
             self.assertEqual(response.status_code, 403)
             self.assertTrue(models.Video.objects.filter(id=video.id).exists())
 
+    def test_api_video_delete_detail_student(self):
+        """Student users should not be able to delete a video."""
+        video = factories.VideoFactory()
+        jwt_token = AccessToken()
+        jwt_token.payload["resource_id"] = str(video.id)
+        jwt_token.payload["roles"] = ["student"]
+
+        response = self.client.delete(
+            f"/api/videos/{video.id}/",
+            HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
+        )
+
+        self.assertEqual(response.status_code, 403)
+        content = json.loads(response.content)
+        self.assertEqual(
+            content, {"detail": "You do not have permission to perform this action."}
+        )
+
     def test_api_video_delete_detail_staff_or_user(self):
         """Users authenticated via a session should not be able to delete a video."""
         video = factories.VideoFactory()
@@ -3076,6 +3200,7 @@ def test_api_video_instructor_initiate_live(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -3125,6 +3250,7 @@ def test_api_video_instructor_initiate_jitsi_live(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -3226,6 +3352,7 @@ def test_api_instructor_start_non_created_live(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -3345,6 +3472,7 @@ def test_api_instructor_start_already_created_live(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -3547,6 +3675,7 @@ def test_api_video_instructor_stop_live(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -3710,6 +3839,7 @@ def test_api_video_instructor_end_idle_live(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
@@ -3791,6 +3921,7 @@ def test_api_video_instructor_end_paused_live(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -3895,6 +4026,7 @@ def test_api_video_instructor_end_paused_live_missing_manifest(self):
                 "id": str(video.id),
                 "title": video.title,
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
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
@@ -144,6 +144,7 @@ def test_views_lti_development_post_bypass_lti_instructor(self):
             context.get("resource"),
             {
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
@@ -216,6 +217,7 @@ def test_views_lti_development_post_bypass_lti_instructor_no_video(self):
             context.get("resource"),
             {
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
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
@@ -105,6 +105,7 @@ def test_views_lti_video_post_instructor(self, mock_get_consumer_site, mock_veri
             context.get("resource"),
             {
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
@@ -227,6 +228,7 @@ def test_views_lti_video_instructor_live_mode_on(
             context.get("resource"),
             {
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -376,6 +378,7 @@ def test_views_lti_video_instructor_live_mode_and_chat_on(
             context.get("resource"),
             {
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -517,6 +520,7 @@ def test_views_lti_video_student_live_mode_on(
             context.get("resource"),
             {
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -640,6 +644,7 @@ def test_views_lti_video_post_administrator(
             context.get("resource"),
             {
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
@@ -720,6 +725,7 @@ def test_views_lti_video_read_other_playlist(
             context.get("resource"),
             {
                 "active_stamp": "1569309880",
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -848,6 +854,7 @@ def test_views_lti_video_restricted_resolutions_list(
             context.get("resource"),
             {
                 "active_stamp": "1569309880",
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -964,6 +971,7 @@ def test_views_lti_video_harvested_upload_state(
             context.get("resource"),
             {
                 "active_stamp": "1569309880",
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
@@ -1082,6 +1090,7 @@ def test_views_lti_video_post_student_with_video(
             context.get("resource"),
             {
                 "active_stamp": "1569309880",
+                "is_public": False,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -1388,6 +1397,7 @@ def test_views_lti_video_has_transcript(self, mock_get_consumer_site, mock_verif
             context.get("resource"),
             {
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
@@ -1478,6 +1488,7 @@ def test_views_lti_video_has_transcript_false(
             context.get("resource"),
             {
                 "active_stamp": None,
+                "is_public": False,
                 "is_ready_to_show": False,
                 "is_scheduled": False,
                 "show_download": True,
diff --git a/src/backend/marsha/core/tests/test_views_public_video.py b/src/backend/marsha/core/tests/test_views_public_video.py
--- a/src/backend/marsha/core/tests/test_views_public_video.py
+++ b/src/backend/marsha/core/tests/test_views_public_video.py
@@ -60,6 +60,7 @@ def test_video_publicly_accessible(self):
             context.get("resource"),
             {
                 "active_stamp": "1569309880",
+                "is_public": True,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,
@@ -235,6 +236,7 @@ def test_video_live_publicly_available(self):
             context.get("resource"),
             {
                 "active_stamp": "1569309880",
+                "is_public": True,
                 "is_ready_to_show": True,
                 "is_scheduled": False,
                 "show_download": True,