We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Create a classroom invite link for an instructor
## Feature Request

**Is your feature request related to a problem or unsupported use case? Please describe.**

We already have for a classroom an invite link. This invite link is dedicated to people without access to the standalone site nor the LTI application. When they use it they are like a student and can only join the meeting when running.
We would like the same feature but for an instructor. When they use a dedicated invite link for them they can use the classroom dashboard.

**Describe the solution you'd like**

When an invite link for instructor is used, the user can use the classroom dashboard.


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `src/backend/marsha/bbb/serializers.py`
Content:
```
1 """Structure of BBB related models API responses with Django Rest Framework serializers."""
2 from datetime import datetime
3 import mimetypes
4 from os.path import splitext
5 from urllib.parse import quote_plus
6 
7 from django.conf import settings
8 from django.core.exceptions import ValidationError
9 from django.db import transaction
10 from django.urls import reverse
11 from django.utils import timezone
12 
13 from rest_framework import serializers
14 
15 from marsha.bbb.models import Classroom, ClassroomDocument, ClassroomRecording
16 from marsha.bbb.utils.bbb_utils import (
17     ApiMeetingException,
18     get_meeting_infos,
19     get_url as get_document_url,
20 )
21 from marsha.bbb.utils.tokens import create_classroom_stable_invite_jwt
22 from marsha.core.serializers import (
23     BaseInitiateUploadSerializer,
24     UploadableFileWithExtensionSerializerMixin,
25 )
26 from marsha.core.serializers.base import ReadOnlyModelSerializer
27 from marsha.core.serializers.playlist import PlaylistLiteSerializer
28 
29 
30 class ClassroomRecordingSerializer(ReadOnlyModelSerializer):
31     """A serializer to display a ClassroomRecording resource."""
32 
33     class Meta:  # noqa
34         model = ClassroomRecording
35         fields = (
36             "id",
37             "classroom",
38             "record_id",
39             "started_at",
40             "video_file_url",
41         )
42         read_only_fields = (
43             "id",
44             "classroom",
45             "record_id",
46             "started_at",
47             "video_file_url",
48         )
49 
50     # Make sure classroom UUID is converted to a string during serialization
51     classroom = serializers.PrimaryKeyRelatedField(
52         read_only=True, pk_field=serializers.CharField()
53     )
54 
55 
56 class ClassroomSerializer(serializers.ModelSerializer):
57     """A serializer to display a Classroom resource."""
58 
59     class Meta:  # noqa
60         model = Classroom
61         fields = (
62             "id",
63             "lti_id",
64             "title",
65             "description",
66             "playlist",
67             "meeting_id",
68             "welcome_text",
69             "started",
70             "ended",
71             "starting_at",
72             "estimated_duration",
73             "recordings",
74             # specific generated fields
75             "infos",
76             "invite_token",
77         )
78         read_only_fields = (
79             "id",
80             "lti_id",
81             "playlist",
82             "meeting_id",
83             "started",
84             "ended",
85             "recordings",
86             "infos",
87         )
88 
89     playlist = PlaylistLiteSerializer(read_only=True)
90     recordings = ClassroomRecordingSerializer(many=True, read_only=True)
91     infos = serializers.SerializerMethodField()
92     invite_token = serializers.SerializerMethodField()
93     recordings = serializers.SerializerMethodField()
94 
95     def get_infos(self, obj):
96         """Meeting infos from BBB server."""
97         try:
98             return get_meeting_infos(classroom=obj)
99         except ApiMeetingException:
100             return None
101 
102     def get_invite_token(self, obj):
103         """Get the invite token for the classroom."""
104         if self.context.get("is_admin", False):
105             return str(create_classroom_stable_invite_jwt(obj))
106         return None
107 
108     def get_recordings(self, obj):
109         """Get the recordings for the classroom.
110 
111         Only available for admins.
112         """
113         if self.context.get("is_admin", True):
114             return ClassroomRecordingSerializer(
115                 obj.recordings.all(), many=True, context=self.context
116             ).data
117         return []
118 
119     def update(self, instance, validated_data):
120         if any(
121             attribute in validated_data
122             for attribute in ["starting_at", "estimated_duration"]
123         ):
124             validated_data["ended"] = False
125         return super().update(instance, validated_data)
126 
127     def validate_starting_at(self, value):
128         """Add extra controls for starting_at field."""
129         # Field starting_at has a new value
130         if value != self.instance.starting_at:
131             # New value is past, it can't be updated
132             if value is not None and value < timezone.now():
133                 sent_date_with_timezone = datetime.fromisoformat(
134                     self.context.get("request").data.get("starting_at")
135                 )
136                 raise serializers.ValidationError(
137                     f"{sent_date_with_timezone} is not a valid date, date should be planned after!"
138                 )
139 
140         return value
141 
142 
143 class ClassroomLiteSerializer(ReadOnlyModelSerializer):
144     """Classroom lite serializer without playlist and infos fetch from the BBB api."""
145 
146     class Meta:  # noqa
147         model = Classroom
148         fields = (
149             "id",
150             "lti_id",
151             "title",
152             "description",
153             "meeting_id",
154             "welcome_text",
155             "started",
156             "ended",
157             "starting_at",
158             "estimated_duration",
159         )
160 
161 
162 class ClassroomSelectLTISerializer(ClassroomSerializer):
163     """A serializer to display a Classroom resource for LTI select content request."""
164 
165     class Meta:  # noqa
166         model = Classroom
167         fields = (
168             "id",
169             "lti_id",
170             "lti_url",
171             "title",
172             "description",
173             "playlist",
174             "meeting_id",
175         )
176 
177     lti_url = serializers.SerializerMethodField()
178 
179     def get_lti_url(self, obj):
180         """LTI Url of the Classroom.
181 
182         Parameters
183         ----------
184         obj : Type[models.Document]
185             The document that we want to serialize
186 
187         Returns
188         -------
189         String
190             the LTI url to be used by LTI consumers
191 
192         """
193         return self.context["request"].build_absolute_uri(
194             reverse("classroom:classroom_lti_view", args=[obj.id]),
195         )
196 
197 
198 class ClassroomDocumentSerializer(
199     UploadableFileWithExtensionSerializerMixin, serializers.ModelSerializer
200 ):
201     """A serializer to display a ClassroomDocument resource."""
202 
203     class Meta:  # noqa
204         model = ClassroomDocument
205         fields = (
206             "classroom",
207             "filename",
208             "id",
209             "is_default",
210             "upload_state",
211             "uploaded_on",
212             "url",
213         )
214         read_only_fields = (
215             "classroom",
216             "id",
217             "upload_state",
218             "uploaded_on",
219             "url",
220         )
221 
222     url = serializers.SerializerMethodField()
223     # Make sure classroom UUID is converted to a string during serialization
224     classroom = serializers.PrimaryKeyRelatedField(
225         read_only=True, pk_field=serializers.CharField()
226     )
227 
228     def to_internal_value(self, data):
229         """Validate if the size is coherent with django settings."""
230         max_file_size = settings.CLASSROOM_DOCUMENT_SOURCE_MAX_SIZE
231         if "filename" in data and "size" not in data:
232             raise ValidationError({"size": ["File size is required"]})
233         if "size" in data and data.pop("size") > max_file_size:
234             raise ValidationError(
235                 {"size": [f"File too large, max size allowed is {max_file_size} Bytes"]}
236             )
237         return super().to_internal_value(data)
238 
239     def create(self, validated_data):
240         """Force the classroom field to the classroom of the JWT Token if any.
241 
242         Parameters
243         ----------
244         validated_data : dictionary
245             Dictionary of the deserialized values of each field after validation.
246 
247         Returns
248         -------
249         dictionary
250             The "validated_data" dictionary is returned after modification.
251 
252         """
253         resource = self.context["request"].resource
254         classroom_id = self.context["request"].data.get("classroom")
255         if not validated_data.get("classroom_id"):
256             if resource:
257                 validated_data["classroom_id"] = resource.id
258             elif classroom_id:
259                 validated_data["classroom_id"] = classroom_id
260 
261         if not ClassroomDocument.objects.filter(
262             classroom_id=validated_data["classroom_id"]
263         ).exists():
264             validated_data["is_default"] = True
265 
266         return super().create(validated_data)
267 
268     def update(self, instance, validated_data):
269         """Set the default attribute of the others classroom documents to false if
270         set to true for this one.
271 
272         Parameters
273         ----------
274         instance : Type[models.ClassroomDocument]
275             The document that we want to update
276 
277         validated_data : dictionary
278             Dictionary of the deserialized values of each field after validation.
279 
280         Returns
281         -------
282         Type[models.ClassroomDocument]
283             The updated document
284 
285         """
286         with transaction.atomic():
287             instance = super().update(instance, validated_data)
288             if validated_data.get("is_default"):
289                 ClassroomDocument.objects.exclude(id=instance.id).filter(
290                     classroom=instance.classroom, is_default=True
291                 ).update(is_default=False)
292             return instance
293 
294     def get_url(self, obj):
295         """Url of the ClassroomDocument.
296 
297         Parameters
298         ----------
299         obj : Type[models.DepositedFile]
300             The classroom document that we want to serialize
301 
302         Returns
303         -------
304         String or None
305             the url to fetch the classroom document on CloudFront
306             None if the classroom document is still not uploaded to S3 with success
307 
308         """
309         if url := get_document_url(obj):
310             return (
311                 f"{url}?response-content-disposition="
312                 f"{quote_plus('attachment; filename=' + obj.filename)}"
313             )
314         return None
315 
316 
317 class ClassroomDocumentInitiateUploadSerializer(BaseInitiateUploadSerializer):
318     """An initiate-upload serializer dedicated to classroom document."""
319 
320     @property
321     def max_upload_file_size(self):
322         """return the class room document max file size define in the settings.
323 
324         The @property decorator is used to ease the use of @override_settings
325         in tests. Otherwise the setting is not changed and we can't easily test
326         an upload with a size higher than the one defined in the settings
327         """
328         return settings.CLASSROOM_DOCUMENT_SOURCE_MAX_SIZE
329 
330     def validate(self, attrs):
331         """Validate if the mimetype is allowed or not."""
332         # mimetype is provided, we directly check it
333         if attrs["mimetype"] != "":
334             if attrs["mimetype"] not in settings.ALLOWED_CLASSROOM_DOCUMENT_MIME_TYPES:
335                 raise serializers.ValidationError(
336                     {"mimetype": f"{attrs['mimetype']} is not a supported mimetype"}
337                 )
338             attrs["extension"] = mimetypes.guess_extension(attrs["mimetype"])
339 
340         # mimetype is not provided, we have to guess it from the extension
341         else:
342             mimetypes.init()
343             extension = splitext(attrs["filename"])[1]
344             mimetype = mimetypes.types_map.get(extension)
345             if mimetype not in settings.ALLOWED_CLASSROOM_DOCUMENT_MIME_TYPES:
346                 raise serializers.ValidationError(
347                     {"mimetype": "mimetype not guessable"}
348                 )
349             # extension is added to the data in order to be used later
350             attrs["extension"] = extension
351             attrs["mimetype"] = mimetype
352 
353         return attrs
354
```

Path: `src/backend/marsha/bbb/utils/tokens.py`
Content:
```
1 """Specific classroom related simple JWT helpers."""
2 from datetime import timedelta
3 
4 from django.conf import settings
5 from django.utils import timezone
6 
7 from marsha.core.simple_jwt.tokens import ResourceAccessToken
8 
9 
10 def create_classroom_stable_invite_jwt(classroom):
11     """Create a resource JWT to be used in classroom invite links.
12 
13     Parameters
14     ----------
15     classroom : Type[models.Classroom]
16         The classroom for which we want to create a JWT.
17 
18     Returns
19     -------
20     ResourceAccessToken
21         The JWT.
22 
23     """
24     resource_jwt = ResourceAccessToken.for_resource_id(
25         resource_id=str(classroom.id),
26         session_id=f"{classroom.id}-invite",
27     )
28 
29     # Set a fixed JWT ID
30     resource_jwt.set_jti(
31         f"classroom-invite-{classroom.id}-{classroom.created_on.strftime('%Y-%m-%d')}"
32     )
33 
34     # Set a fixed validity beginning: the classroom creation date
35     resource_jwt.set_iat(at_time=classroom.created_on)
36 
37     # Determine the validity end:
38     # - if the classroom has a starting date, the JWT is valid
39     #   until the starting date plus two days
40     # - if the classroom has no starting date, the JWT is valid
41     #   for a month **starting now** (not on classroom creation)
42     if classroom.starting_at:
43         validity_end = classroom.starting_at + timedelta(days=2)
44     else:
45         validity_end = timezone.now().replace(
46             hour=0, minute=0, second=0, microsecond=0
47         ) + timedelta(days=settings.BBB_INVITE_JWT_DEFAULT_DAYS_DURATION)
48 
49     resource_jwt.set_exp(
50         from_time=classroom.created_on,
51         lifetime=validity_end - classroom.created_on,
52     )
53 
54     return resource_jwt
55
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/src/backend/marsha/bbb/serializers.py b/src/backend/marsha/bbb/serializers.py
--- a/src/backend/marsha/bbb/serializers.py
+++ b/src/backend/marsha/bbb/serializers.py
@@ -19,6 +19,7 @@
     get_url as get_document_url,
 )
 from marsha.bbb.utils.tokens import create_classroom_stable_invite_jwt
+from marsha.core.models import INSTRUCTOR
 from marsha.core.serializers import (
     BaseInitiateUploadSerializer,
     UploadableFileWithExtensionSerializerMixin,
@@ -74,6 +75,7 @@
             # specific generated fields
             "infos",
             "invite_token",
+            "instructor_token",
         )
         read_only_fields = (
             "id",
@@ -90,6 +92,7 @@
     recordings = ClassroomRecordingSerializer(many=True, read_only=True)
     infos = serializers.SerializerMethodField()
     invite_token = serializers.SerializerMethodField()
+    instructor_token = serializers.SerializerMethodField()
     recordings = serializers.SerializerMethodField()
 
     def get_infos(self, obj):
@@ -105,6 +108,18 @@
             return str(create_classroom_stable_invite_jwt(obj))
         return None
 
+    def get_instructor_token(self, obj):
+        """Get the instructor token for the classroom."""
+        if self.context.get("is_admin", False):
+            return str(
+                create_classroom_stable_invite_jwt(
+                    obj,
+                    role=INSTRUCTOR,
+                    permissions={"can_update": True, "can_access_dashboard": True},
+                )
+            )
+        return None
+
     def get_recordings(self, obj):
         """Get the recordings for the classroom.
 
diff --git a/src/backend/marsha/bbb/utils/tokens.py b/src/backend/marsha/bbb/utils/tokens.py
--- a/src/backend/marsha/bbb/utils/tokens.py
+++ b/src/backend/marsha/bbb/utils/tokens.py
@@ -4,10 +4,11 @@
 from django.conf import settings
 from django.utils import timezone
 
+from marsha.core.models import NONE
 from marsha.core.simple_jwt.tokens import ResourceAccessToken
 
 
-def create_classroom_stable_invite_jwt(classroom):
+def create_classroom_stable_invite_jwt(classroom, role=NONE, permissions=None):
     """Create a resource JWT to be used in classroom invite links.
 
     Parameters
@@ -15,6 +16,12 @@
     classroom : Type[models.Classroom]
         The classroom for which we want to create a JWT.
 
+    role : str
+        The role to use in the JWT. If not set, the no role is used.
+
+    permissions : dict
+        The permissions to use in the JWT. If not set, no permissions are used.
+
     Returns
     -------
     ResourceAccessToken
@@ -24,6 +31,8 @@
     resource_jwt = ResourceAccessToken.for_resource_id(
         resource_id=str(classroom.id),
         session_id=f"{classroom.id}-invite",
+        roles=[role],
+        permissions=permissions or {},
     )
 
     # Set a fixed JWT ID

{"golden_diff": "diff --git a/src/backend/marsha/bbb/serializers.py b/src/backend/marsha/bbb/serializers.py\n--- a/src/backend/marsha/bbb/serializers.py\n+++ b/src/backend/marsha/bbb/serializers.py\n@@ -19,6 +19,7 @@\n     get_url as get_document_url,\n )\n from marsha.bbb.utils.tokens import create_classroom_stable_invite_jwt\n+from marsha.core.models import INSTRUCTOR\n from marsha.core.serializers import (\n     BaseInitiateUploadSerializer,\n     UploadableFileWithExtensionSerializerMixin,\n@@ -74,6 +75,7 @@\n             # specific generated fields\n             \"infos\",\n             \"invite_token\",\n+            \"instructor_token\",\n         )\n         read_only_fields = (\n             \"id\",\n@@ -90,6 +92,7 @@\n     recordings = ClassroomRecordingSerializer(many=True, read_only=True)\n     infos = serializers.SerializerMethodField()\n     invite_token = serializers.SerializerMethodField()\n+    instructor_token = serializers.SerializerMethodField()\n     recordings = serializers.SerializerMethodField()\n \n     def get_infos(self, obj):\n@@ -105,6 +108,18 @@\n             return str(create_classroom_stable_invite_jwt(obj))\n         return None\n \n+    def get_instructor_token(self, obj):\n+        \"\"\"Get the instructor token for the classroom.\"\"\"\n+        if self.context.get(\"is_admin\", False):\n+            return str(\n+                create_classroom_stable_invite_jwt(\n+                    obj,\n+                    role=INSTRUCTOR,\n+                    permissions={\"can_update\": True, \"can_access_dashboard\": True},\n+                )\n+            )\n+        return None\n+\n     def get_recordings(self, obj):\n         \"\"\"Get the recordings for the classroom.\n \ndiff --git a/src/backend/marsha/bbb/utils/tokens.py b/src/backend/marsha/bbb/utils/tokens.py\n--- a/src/backend/marsha/bbb/utils/tokens.py\n+++ b/src/backend/marsha/bbb/utils/tokens.py\n@@ -4,10 +4,11 @@\n from django.conf import settings\n from django.utils import timezone\n \n+from marsha.core.models import NONE\n from marsha.core.simple_jwt.tokens import ResourceAccessToken\n \n \n-def create_classroom_stable_invite_jwt(classroom):\n+def create_classroom_stable_invite_jwt(classroom, role=NONE, permissions=None):\n     \"\"\"Create a resource JWT to be used in classroom invite links.\n \n     Parameters\n@@ -15,6 +16,12 @@\n     classroom : Type[models.Classroom]\n         The classroom for which we want to create a JWT.\n \n+    role : str\n+        The role to use in the JWT. If not set, the no role is used.\n+\n+    permissions : dict\n+        The permissions to use in the JWT. If not set, no permissions are used.\n+\n     Returns\n     -------\n     ResourceAccessToken\n@@ -24,6 +31,8 @@\n     resource_jwt = ResourceAccessToken.for_resource_id(\n         resource_id=str(classroom.id),\n         session_id=f\"{classroom.id}-invite\",\n+        roles=[role],\n+        permissions=permissions or {},\n     )\n \n     # Set a fixed JWT ID\n", "issue": "Create a classroom invite link for an instructor\n## Feature Request\r\n\r\n**Is your feature request related to a problem or unsupported use case? Please describe.**\r\n\r\nWe already have for a classroom an invite link. This invite link is dedicated to people without access to the standalone site nor the LTI application. When they use it they are like a student and can only join the meeting when running.\r\nWe would like the same feature but for an instructor. When they use a dedicated invite link for them they can use the classroom dashboard.\r\n\r\n**Describe the solution you'd like**\r\n\r\nWhen an invite link for instructor is used, the user can use the classroom dashboard.\r\n\n", "before_files": [{"content": "\"\"\"Structure of BBB related models API responses with Django Rest Framework serializers.\"\"\"\nfrom datetime import datetime\nimport mimetypes\nfrom os.path import splitext\nfrom urllib.parse import quote_plus\n\nfrom django.conf import settings\nfrom django.core.exceptions import ValidationError\nfrom django.db import transaction\nfrom django.urls import reverse\nfrom django.utils import timezone\n\nfrom rest_framework import serializers\n\nfrom marsha.bbb.models import Classroom, ClassroomDocument, ClassroomRecording\nfrom marsha.bbb.utils.bbb_utils import (\n    ApiMeetingException,\n    get_meeting_infos,\n    get_url as get_document_url,\n)\nfrom marsha.bbb.utils.tokens import create_classroom_stable_invite_jwt\nfrom marsha.core.serializers import (\n    BaseInitiateUploadSerializer,\n    UploadableFileWithExtensionSerializerMixin,\n)\nfrom marsha.core.serializers.base import ReadOnlyModelSerializer\nfrom marsha.core.serializers.playlist import PlaylistLiteSerializer\n\n\nclass ClassroomRecordingSerializer(ReadOnlyModelSerializer):\n    \"\"\"A serializer to display a ClassroomRecording resource.\"\"\"\n\n    class Meta:  # noqa\n        model = ClassroomRecording\n        fields = (\n            \"id\",\n            \"classroom\",\n            \"record_id\",\n            \"started_at\",\n            \"video_file_url\",\n        )\n        read_only_fields = (\n            \"id\",\n            \"classroom\",\n            \"record_id\",\n            \"started_at\",\n            \"video_file_url\",\n        )\n\n    # Make sure classroom UUID is converted to a string during serialization\n    classroom = serializers.PrimaryKeyRelatedField(\n        read_only=True, pk_field=serializers.CharField()\n    )\n\n\nclass ClassroomSerializer(serializers.ModelSerializer):\n    \"\"\"A serializer to display a Classroom resource.\"\"\"\n\n    class Meta:  # noqa\n        model = Classroom\n        fields = (\n            \"id\",\n            \"lti_id\",\n            \"title\",\n            \"description\",\n            \"playlist\",\n            \"meeting_id\",\n            \"welcome_text\",\n            \"started\",\n            \"ended\",\n            \"starting_at\",\n            \"estimated_duration\",\n            \"recordings\",\n            # specific generated fields\n            \"infos\",\n            \"invite_token\",\n        )\n        read_only_fields = (\n            \"id\",\n            \"lti_id\",\n            \"playlist\",\n            \"meeting_id\",\n            \"started\",\n            \"ended\",\n            \"recordings\",\n            \"infos\",\n        )\n\n    playlist = PlaylistLiteSerializer(read_only=True)\n    recordings = ClassroomRecordingSerializer(many=True, read_only=True)\n    infos = serializers.SerializerMethodField()\n    invite_token = serializers.SerializerMethodField()\n    recordings = serializers.SerializerMethodField()\n\n    def get_infos(self, obj):\n        \"\"\"Meeting infos from BBB server.\"\"\"\n        try:\n            return get_meeting_infos(classroom=obj)\n        except ApiMeetingException:\n            return None\n\n    def get_invite_token(self, obj):\n        \"\"\"Get the invite token for the classroom.\"\"\"\n        if self.context.get(\"is_admin\", False):\n            return str(create_classroom_stable_invite_jwt(obj))\n        return None\n\n    def get_recordings(self, obj):\n        \"\"\"Get the recordings for the classroom.\n\n        Only available for admins.\n        \"\"\"\n        if self.context.get(\"is_admin\", True):\n            return ClassroomRecordingSerializer(\n                obj.recordings.all(), many=True, context=self.context\n            ).data\n        return []\n\n    def update(self, instance, validated_data):\n        if any(\n            attribute in validated_data\n            for attribute in [\"starting_at\", \"estimated_duration\"]\n        ):\n            validated_data[\"ended\"] = False\n        return super().update(instance, validated_data)\n\n    def validate_starting_at(self, value):\n        \"\"\"Add extra controls for starting_at field.\"\"\"\n        # Field starting_at has a new value\n        if value != self.instance.starting_at:\n            # New value is past, it can't be updated\n            if value is not None and value < timezone.now():\n                sent_date_with_timezone = datetime.fromisoformat(\n                    self.context.get(\"request\").data.get(\"starting_at\")\n                )\n                raise serializers.ValidationError(\n                    f\"{sent_date_with_timezone} is not a valid date, date should be planned after!\"\n                )\n\n        return value\n\n\nclass ClassroomLiteSerializer(ReadOnlyModelSerializer):\n    \"\"\"Classroom lite serializer without playlist and infos fetch from the BBB api.\"\"\"\n\n    class Meta:  # noqa\n        model = Classroom\n        fields = (\n            \"id\",\n            \"lti_id\",\n            \"title\",\n            \"description\",\n            \"meeting_id\",\n            \"welcome_text\",\n            \"started\",\n            \"ended\",\n            \"starting_at\",\n            \"estimated_duration\",\n        )\n\n\nclass ClassroomSelectLTISerializer(ClassroomSerializer):\n    \"\"\"A serializer to display a Classroom resource for LTI select content request.\"\"\"\n\n    class Meta:  # noqa\n        model = Classroom\n        fields = (\n            \"id\",\n            \"lti_id\",\n            \"lti_url\",\n            \"title\",\n            \"description\",\n            \"playlist\",\n            \"meeting_id\",\n        )\n\n    lti_url = serializers.SerializerMethodField()\n\n    def get_lti_url(self, obj):\n        \"\"\"LTI Url of the Classroom.\n\n        Parameters\n        ----------\n        obj : Type[models.Document]\n            The document that we want to serialize\n\n        Returns\n        -------\n        String\n            the LTI url to be used by LTI consumers\n\n        \"\"\"\n        return self.context[\"request\"].build_absolute_uri(\n            reverse(\"classroom:classroom_lti_view\", args=[obj.id]),\n        )\n\n\nclass ClassroomDocumentSerializer(\n    UploadableFileWithExtensionSerializerMixin, serializers.ModelSerializer\n):\n    \"\"\"A serializer to display a ClassroomDocument resource.\"\"\"\n\n    class Meta:  # noqa\n        model = ClassroomDocument\n        fields = (\n            \"classroom\",\n            \"filename\",\n            \"id\",\n            \"is_default\",\n            \"upload_state\",\n            \"uploaded_on\",\n            \"url\",\n        )\n        read_only_fields = (\n            \"classroom\",\n            \"id\",\n            \"upload_state\",\n            \"uploaded_on\",\n            \"url\",\n        )\n\n    url = serializers.SerializerMethodField()\n    # Make sure classroom UUID is converted to a string during serialization\n    classroom = serializers.PrimaryKeyRelatedField(\n        read_only=True, pk_field=serializers.CharField()\n    )\n\n    def to_internal_value(self, data):\n        \"\"\"Validate if the size is coherent with django settings.\"\"\"\n        max_file_size = settings.CLASSROOM_DOCUMENT_SOURCE_MAX_SIZE\n        if \"filename\" in data and \"size\" not in data:\n            raise ValidationError({\"size\": [\"File size is required\"]})\n        if \"size\" in data and data.pop(\"size\") > max_file_size:\n            raise ValidationError(\n                {\"size\": [f\"File too large, max size allowed is {max_file_size} Bytes\"]}\n            )\n        return super().to_internal_value(data)\n\n    def create(self, validated_data):\n        \"\"\"Force the classroom field to the classroom of the JWT Token if any.\n\n        Parameters\n        ----------\n        validated_data : dictionary\n            Dictionary of the deserialized values of each field after validation.\n\n        Returns\n        -------\n        dictionary\n            The \"validated_data\" dictionary is returned after modification.\n\n        \"\"\"\n        resource = self.context[\"request\"].resource\n        classroom_id = self.context[\"request\"].data.get(\"classroom\")\n        if not validated_data.get(\"classroom_id\"):\n            if resource:\n                validated_data[\"classroom_id\"] = resource.id\n            elif classroom_id:\n                validated_data[\"classroom_id\"] = classroom_id\n\n        if not ClassroomDocument.objects.filter(\n            classroom_id=validated_data[\"classroom_id\"]\n        ).exists():\n            validated_data[\"is_default\"] = True\n\n        return super().create(validated_data)\n\n    def update(self, instance, validated_data):\n        \"\"\"Set the default attribute of the others classroom documents to false if\n        set to true for this one.\n\n        Parameters\n        ----------\n        instance : Type[models.ClassroomDocument]\n            The document that we want to update\n\n        validated_data : dictionary\n            Dictionary of the deserialized values of each field after validation.\n\n        Returns\n        -------\n        Type[models.ClassroomDocument]\n            The updated document\n\n        \"\"\"\n        with transaction.atomic():\n            instance = super().update(instance, validated_data)\n            if validated_data.get(\"is_default\"):\n                ClassroomDocument.objects.exclude(id=instance.id).filter(\n                    classroom=instance.classroom, is_default=True\n                ).update(is_default=False)\n            return instance\n\n    def get_url(self, obj):\n        \"\"\"Url of the ClassroomDocument.\n\n        Parameters\n        ----------\n        obj : Type[models.DepositedFile]\n            The classroom document that we want to serialize\n\n        Returns\n        -------\n        String or None\n            the url to fetch the classroom document on CloudFront\n            None if the classroom document is still not uploaded to S3 with success\n\n        \"\"\"\n        if url := get_document_url(obj):\n            return (\n                f\"{url}?response-content-disposition=\"\n                f\"{quote_plus('attachment; filename=' + obj.filename)}\"\n            )\n        return None\n\n\nclass ClassroomDocumentInitiateUploadSerializer(BaseInitiateUploadSerializer):\n    \"\"\"An initiate-upload serializer dedicated to classroom document.\"\"\"\n\n    @property\n    def max_upload_file_size(self):\n        \"\"\"return the class room document max file size define in the settings.\n\n        The @property decorator is used to ease the use of @override_settings\n        in tests. Otherwise the setting is not changed and we can't easily test\n        an upload with a size higher than the one defined in the settings\n        \"\"\"\n        return settings.CLASSROOM_DOCUMENT_SOURCE_MAX_SIZE\n\n    def validate(self, attrs):\n        \"\"\"Validate if the mimetype is allowed or not.\"\"\"\n        # mimetype is provided, we directly check it\n        if attrs[\"mimetype\"] != \"\":\n            if attrs[\"mimetype\"] not in settings.ALLOWED_CLASSROOM_DOCUMENT_MIME_TYPES:\n                raise serializers.ValidationError(\n                    {\"mimetype\": f\"{attrs['mimetype']} is not a supported mimetype\"}\n                )\n            attrs[\"extension\"] = mimetypes.guess_extension(attrs[\"mimetype\"])\n\n        # mimetype is not provided, we have to guess it from the extension\n        else:\n            mimetypes.init()\n            extension = splitext(attrs[\"filename\"])[1]\n            mimetype = mimetypes.types_map.get(extension)\n            if mimetype not in settings.ALLOWED_CLASSROOM_DOCUMENT_MIME_TYPES:\n                raise serializers.ValidationError(\n                    {\"mimetype\": \"mimetype not guessable\"}\n                )\n            # extension is added to the data in order to be used later\n            attrs[\"extension\"] = extension\n            attrs[\"mimetype\"] = mimetype\n\n        return attrs\n", "path": "src/backend/marsha/bbb/serializers.py"}, {"content": "\"\"\"Specific classroom related simple JWT helpers.\"\"\"\nfrom datetime import timedelta\n\nfrom django.conf import settings\nfrom django.utils import timezone\n\nfrom marsha.core.simple_jwt.tokens import ResourceAccessToken\n\n\ndef create_classroom_stable_invite_jwt(classroom):\n    \"\"\"Create a resource JWT to be used in classroom invite links.\n\n    Parameters\n    ----------\n    classroom : Type[models.Classroom]\n        The classroom for which we want to create a JWT.\n\n    Returns\n    -------\n    ResourceAccessToken\n        The JWT.\n\n    \"\"\"\n    resource_jwt = ResourceAccessToken.for_resource_id(\n        resource_id=str(classroom.id),\n        session_id=f\"{classroom.id}-invite\",\n    )\n\n    # Set a fixed JWT ID\n    resource_jwt.set_jti(\n        f\"classroom-invite-{classroom.id}-{classroom.created_on.strftime('%Y-%m-%d')}\"\n    )\n\n    # Set a fixed validity beginning: the classroom creation date\n    resource_jwt.set_iat(at_time=classroom.created_on)\n\n    # Determine the validity end:\n    # - if the classroom has a starting date, the JWT is valid\n    #   until the starting date plus two days\n    # - if the classroom has no starting date, the JWT is valid\n    #   for a month **starting now** (not on classroom creation)\n    if classroom.starting_at:\n        validity_end = classroom.starting_at + timedelta(days=2)\n    else:\n        validity_end = timezone.now().replace(\n            hour=0, minute=0, second=0, microsecond=0\n        ) + timedelta(days=settings.BBB_INVITE_JWT_DEFAULT_DAYS_DURATION)\n\n    resource_jwt.set_exp(\n        from_time=classroom.created_on,\n        lifetime=validity_end - classroom.created_on,\n    )\n\n    return resource_jwt\n", "path": "src/backend/marsha/bbb/utils/tokens.py"}], "after_files": [{"content": "\"\"\"Structure of BBB related models API responses with Django Rest Framework serializers.\"\"\"\nfrom datetime import datetime\nimport mimetypes\nfrom os.path import splitext\nfrom urllib.parse import quote_plus\n\nfrom django.conf import settings\nfrom django.core.exceptions import ValidationError\nfrom django.db import transaction\nfrom django.urls import reverse\nfrom django.utils import timezone\n\nfrom rest_framework import serializers\n\nfrom marsha.bbb.models import Classroom, ClassroomDocument, ClassroomRecording\nfrom marsha.bbb.utils.bbb_utils import (\n    ApiMeetingException,\n    get_meeting_infos,\n    get_url as get_document_url,\n)\nfrom marsha.bbb.utils.tokens import create_classroom_stable_invite_jwt\nfrom marsha.core.models import INSTRUCTOR\nfrom marsha.core.serializers import (\n    BaseInitiateUploadSerializer,\n    UploadableFileWithExtensionSerializerMixin,\n)\nfrom marsha.core.serializers.base import ReadOnlyModelSerializer\nfrom marsha.core.serializers.playlist import PlaylistLiteSerializer\n\n\nclass ClassroomRecordingSerializer(ReadOnlyModelSerializer):\n    \"\"\"A serializer to display a ClassroomRecording resource.\"\"\"\n\n    class Meta:  # noqa\n        model = ClassroomRecording\n        fields = (\n            \"id\",\n            \"classroom\",\n            \"record_id\",\n            \"started_at\",\n            \"video_file_url\",\n        )\n        read_only_fields = (\n            \"id\",\n            \"classroom\",\n            \"record_id\",\n            \"started_at\",\n            \"video_file_url\",\n        )\n\n    # Make sure classroom UUID is converted to a string during serialization\n    classroom = serializers.PrimaryKeyRelatedField(\n        read_only=True, pk_field=serializers.CharField()\n    )\n\n\nclass ClassroomSerializer(serializers.ModelSerializer):\n    \"\"\"A serializer to display a Classroom resource.\"\"\"\n\n    class Meta:  # noqa\n        model = Classroom\n        fields = (\n            \"id\",\n            \"lti_id\",\n            \"title\",\n            \"description\",\n            \"playlist\",\n            \"meeting_id\",\n            \"welcome_text\",\n            \"started\",\n            \"ended\",\n            \"starting_at\",\n            \"estimated_duration\",\n            \"recordings\",\n            # specific generated fields\n            \"infos\",\n            \"invite_token\",\n            \"instructor_token\",\n        )\n        read_only_fields = (\n            \"id\",\n            \"lti_id\",\n            \"playlist\",\n            \"meeting_id\",\n            \"started\",\n            \"ended\",\n            \"recordings\",\n            \"infos\",\n        )\n\n    playlist = PlaylistLiteSerializer(read_only=True)\n    recordings = ClassroomRecordingSerializer(many=True, read_only=True)\n    infos = serializers.SerializerMethodField()\n    invite_token = serializers.SerializerMethodField()\n    instructor_token = serializers.SerializerMethodField()\n    recordings = serializers.SerializerMethodField()\n\n    def get_infos(self, obj):\n        \"\"\"Meeting infos from BBB server.\"\"\"\n        try:\n            return get_meeting_infos(classroom=obj)\n        except ApiMeetingException:\n            return None\n\n    def get_invite_token(self, obj):\n        \"\"\"Get the invite token for the classroom.\"\"\"\n        if self.context.get(\"is_admin\", False):\n            return str(create_classroom_stable_invite_jwt(obj))\n        return None\n\n    def get_instructor_token(self, obj):\n        \"\"\"Get the instructor token for the classroom.\"\"\"\n        if self.context.get(\"is_admin\", False):\n            return str(\n                create_classroom_stable_invite_jwt(\n                    obj,\n                    role=INSTRUCTOR,\n                    permissions={\"can_update\": True, \"can_access_dashboard\": True},\n                )\n            )\n        return None\n\n    def get_recordings(self, obj):\n        \"\"\"Get the recordings for the classroom.\n\n        Only available for admins.\n        \"\"\"\n        if self.context.get(\"is_admin\", True):\n            return ClassroomRecordingSerializer(\n                obj.recordings.all(), many=True, context=self.context\n            ).data\n        return []\n\n    def update(self, instance, validated_data):\n        if any(\n            attribute in validated_data\n            for attribute in [\"starting_at\", \"estimated_duration\"]\n        ):\n            validated_data[\"ended\"] = False\n        return super().update(instance, validated_data)\n\n    def validate_starting_at(self, value):\n        \"\"\"Add extra controls for starting_at field.\"\"\"\n        # Field starting_at has a new value\n        if value != self.instance.starting_at:\n            # New value is past, it can't be updated\n            if value is not None and value < timezone.now():\n                sent_date_with_timezone = datetime.fromisoformat(\n                    self.context.get(\"request\").data.get(\"starting_at\")\n                )\n                raise serializers.ValidationError(\n                    f\"{sent_date_with_timezone} is not a valid date, date should be planned after!\"\n                )\n\n        return value\n\n\nclass ClassroomLiteSerializer(ReadOnlyModelSerializer):\n    \"\"\"Classroom lite serializer without playlist and infos fetch from the BBB api.\"\"\"\n\n    class Meta:  # noqa\n        model = Classroom\n        fields = (\n            \"id\",\n            \"lti_id\",\n            \"title\",\n            \"description\",\n            \"meeting_id\",\n            \"welcome_text\",\n            \"started\",\n            \"ended\",\n            \"starting_at\",\n            \"estimated_duration\",\n        )\n\n\nclass ClassroomSelectLTISerializer(ClassroomSerializer):\n    \"\"\"A serializer to display a Classroom resource for LTI select content request.\"\"\"\n\n    class Meta:  # noqa\n        model = Classroom\n        fields = (\n            \"id\",\n            \"lti_id\",\n            \"lti_url\",\n            \"title\",\n            \"description\",\n            \"playlist\",\n            \"meeting_id\",\n        )\n\n    lti_url = serializers.SerializerMethodField()\n\n    def get_lti_url(self, obj):\n        \"\"\"LTI Url of the Classroom.\n\n        Parameters\n        ----------\n        obj : Type[models.Document]\n            The document that we want to serialize\n\n        Returns\n        -------\n        String\n            the LTI url to be used by LTI consumers\n\n        \"\"\"\n        return self.context[\"request\"].build_absolute_uri(\n            reverse(\"classroom:classroom_lti_view\", args=[obj.id]),\n        )\n\n\nclass ClassroomDocumentSerializer(\n    UploadableFileWithExtensionSerializerMixin, serializers.ModelSerializer\n):\n    \"\"\"A serializer to display a ClassroomDocument resource.\"\"\"\n\n    class Meta:  # noqa\n        model = ClassroomDocument\n        fields = (\n            \"classroom\",\n            \"filename\",\n            \"id\",\n            \"is_default\",\n            \"upload_state\",\n            \"uploaded_on\",\n            \"url\",\n        )\n        read_only_fields = (\n            \"classroom\",\n            \"id\",\n            \"upload_state\",\n            \"uploaded_on\",\n            \"url\",\n        )\n\n    url = serializers.SerializerMethodField()\n    # Make sure classroom UUID is converted to a string during serialization\n    classroom = serializers.PrimaryKeyRelatedField(\n        read_only=True, pk_field=serializers.CharField()\n    )\n\n    def to_internal_value(self, data):\n        \"\"\"Validate if the size is coherent with django settings.\"\"\"\n        max_file_size = settings.CLASSROOM_DOCUMENT_SOURCE_MAX_SIZE\n        if \"filename\" in data and \"size\" not in data:\n            raise ValidationError({\"size\": [\"File size is required\"]})\n        if \"size\" in data and data.pop(\"size\") > max_file_size:\n            raise ValidationError(\n                {\"size\": [f\"File too large, max size allowed is {max_file_size} Bytes\"]}\n            )\n        return super().to_internal_value(data)\n\n    def create(self, validated_data):\n        \"\"\"Force the classroom field to the classroom of the JWT Token if any.\n\n        Parameters\n        ----------\n        validated_data : dictionary\n            Dictionary of the deserialized values of each field after validation.\n\n        Returns\n        -------\n        dictionary\n            The \"validated_data\" dictionary is returned after modification.\n\n        \"\"\"\n        resource = self.context[\"request\"].resource\n        classroom_id = self.context[\"request\"].data.get(\"classroom\")\n        if not validated_data.get(\"classroom_id\"):\n            if resource:\n                validated_data[\"classroom_id\"] = resource.id\n            elif classroom_id:\n                validated_data[\"classroom_id\"] = classroom_id\n\n        if not ClassroomDocument.objects.filter(\n            classroom_id=validated_data[\"classroom_id\"]\n        ).exists():\n            validated_data[\"is_default\"] = True\n\n        return super().create(validated_data)\n\n    def update(self, instance, validated_data):\n        \"\"\"Set the default attribute of the others classroom documents to false if\n        set to true for this one.\n\n        Parameters\n        ----------\n        instance : Type[models.ClassroomDocument]\n            The document that we want to update\n\n        validated_data : dictionary\n            Dictionary of the deserialized values of each field after validation.\n\n        Returns\n        -------\n        Type[models.ClassroomDocument]\n            The updated document\n\n        \"\"\"\n        with transaction.atomic():\n            instance = super().update(instance, validated_data)\n            if validated_data.get(\"is_default\"):\n                ClassroomDocument.objects.exclude(id=instance.id).filter(\n                    classroom=instance.classroom, is_default=True\n                ).update(is_default=False)\n            return instance\n\n    def get_url(self, obj):\n        \"\"\"Url of the ClassroomDocument.\n\n        Parameters\n        ----------\n        obj : Type[models.DepositedFile]\n            The classroom document that we want to serialize\n\n        Returns\n        -------\n        String or None\n            the url to fetch the classroom document on CloudFront\n            None if the classroom document is still not uploaded to S3 with success\n\n        \"\"\"\n        if url := get_document_url(obj):\n            return (\n                f\"{url}?response-content-disposition=\"\n                f\"{quote_plus('attachment; filename=' + obj.filename)}\"\n            )\n        return None\n\n\nclass ClassroomDocumentInitiateUploadSerializer(BaseInitiateUploadSerializer):\n    \"\"\"An initiate-upload serializer dedicated to classroom document.\"\"\"\n\n    @property\n    def max_upload_file_size(self):\n        \"\"\"return the class room document max file size define in the settings.\n\n        The @property decorator is used to ease the use of @override_settings\n        in tests. Otherwise the setting is not changed and we can't easily test\n        an upload with a size higher than the one defined in the settings\n        \"\"\"\n        return settings.CLASSROOM_DOCUMENT_SOURCE_MAX_SIZE\n\n    def validate(self, attrs):\n        \"\"\"Validate if the mimetype is allowed or not.\"\"\"\n        # mimetype is provided, we directly check it\n        if attrs[\"mimetype\"] != \"\":\n            if attrs[\"mimetype\"] not in settings.ALLOWED_CLASSROOM_DOCUMENT_MIME_TYPES:\n                raise serializers.ValidationError(\n                    {\"mimetype\": f\"{attrs['mimetype']} is not a supported mimetype\"}\n                )\n            attrs[\"extension\"] = mimetypes.guess_extension(attrs[\"mimetype\"])\n\n        # mimetype is not provided, we have to guess it from the extension\n        else:\n            mimetypes.init()\n            extension = splitext(attrs[\"filename\"])[1]\n            mimetype = mimetypes.types_map.get(extension)\n            if mimetype not in settings.ALLOWED_CLASSROOM_DOCUMENT_MIME_TYPES:\n                raise serializers.ValidationError(\n                    {\"mimetype\": \"mimetype not guessable\"}\n                )\n            # extension is added to the data in order to be used later\n            attrs[\"extension\"] = extension\n            attrs[\"mimetype\"] = mimetype\n\n        return attrs\n", "path": "src/backend/marsha/bbb/serializers.py"}, {"content": "\"\"\"Specific classroom related simple JWT helpers.\"\"\"\nfrom datetime import timedelta\n\nfrom django.conf import settings\nfrom django.utils import timezone\n\nfrom marsha.core.models import NONE\nfrom marsha.core.simple_jwt.tokens import ResourceAccessToken\n\n\ndef create_classroom_stable_invite_jwt(classroom, role=NONE, permissions=None):\n    \"\"\"Create a resource JWT to be used in classroom invite links.\n\n    Parameters\n    ----------\n    classroom : Type[models.Classroom]\n        The classroom for which we want to create a JWT.\n\n    role : str\n        The role to use in the JWT. If not set, the no role is used.\n\n    permissions : dict\n        The permissions to use in the JWT. If not set, no permissions are used.\n\n    Returns\n    -------\n    ResourceAccessToken\n        The JWT.\n\n    \"\"\"\n    resource_jwt = ResourceAccessToken.for_resource_id(\n        resource_id=str(classroom.id),\n        session_id=f\"{classroom.id}-invite\",\n        roles=[role],\n        permissions=permissions or {},\n    )\n\n    # Set a fixed JWT ID\n    resource_jwt.set_jti(\n        f\"classroom-invite-{classroom.id}-{classroom.created_on.strftime('%Y-%m-%d')}\"\n    )\n\n    # Set a fixed validity beginning: the classroom creation date\n    resource_jwt.set_iat(at_time=classroom.created_on)\n\n    # Determine the validity end:\n    # - if the classroom has a starting date, the JWT is valid\n    #   until the starting date plus two days\n    # - if the classroom has no starting date, the JWT is valid\n    #   for a month **starting now** (not on classroom creation)\n    if classroom.starting_at:\n        validity_end = classroom.starting_at + timedelta(days=2)\n    else:\n        validity_end = timezone.now().replace(\n            hour=0, minute=0, second=0, microsecond=0\n        ) + timedelta(days=settings.BBB_INVITE_JWT_DEFAULT_DAYS_DURATION)\n\n    resource_jwt.set_exp(\n        from_time=classroom.created_on,\n        lifetime=validity_end - classroom.created_on,\n    )\n\n    return resource_jwt\n", "path": "src/backend/marsha/bbb/utils/tokens.py"}]}