Skip to content

Commit 6a90624

Browse files
committed
✨(back) add support for storing classroom documents on Scaleway S3
Leverage the existing django-storages backend to enable storing classroom documents on Scaleway S3, alongside on the AWS S3. For now we support both storage for a smooth transition away from AWS S3.
1 parent 7035f2b commit 6a90624

File tree

19 files changed

+718
-158
lines changed

19 files changed

+718
-158
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ Versioning](https://semver.org/spec/v2.0.0.html).
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- Add support for storing classroom documents on Scaleway S3
14+
1115
### Fixed
1216

1317
- Fix timeout increase on Nginx for peertube runner success request

src/backend/marsha/bbb/api.py

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,13 @@
3535
process_recordings,
3636
)
3737
from marsha.bbb.utils.tokens import create_classroom_stable_invite_jwt
38-
from marsha.core import defaults, permissions as core_permissions
38+
from marsha.core import defaults, permissions as core_permissions, storage
3939
from marsha.core.api import APIViewMixin, BulkDestroyModelMixin, ObjectPkMixin
40-
from marsha.core.defaults import VOD_CONVERT
40+
from marsha.core.defaults import READY, VOD_CONVERT
4141
from marsha.core.models import ADMINISTRATOR, INSTRUCTOR, Video
4242
from marsha.core.tasks.recording import copy_video_recording
4343
from marsha.core.tasks.video import launch_video_transcoding
44-
from marsha.core.utils.s3_utils import create_presigned_post
45-
from marsha.core.utils.time_utils import to_timestamp
44+
from marsha.core.utils.time_utils import to_datetime, to_timestamp
4645

4746

4847
class ObjectClassroomRelatedMixin:
@@ -615,32 +614,27 @@ def initiate_upload(self, request, pk=None, classroom_id=None):
615614
HttpResponse carrying the AWS S3 upload policy as a JSON object.
616615
617616
"""
618-
classroom = self.get_object() # check permissions first
617+
classroom_document = self.get_object() # check permissions first
619618

620619
serializer = serializers.ClassroomDocumentInitiateUploadSerializer(
621620
data=request.data
622621
)
623622
if serializer.is_valid() is not True:
624623
return Response(serializer.errors, status=400)
625624

626-
now = timezone.now()
627-
stamp = to_timestamp(now)
628-
629-
key = classroom.get_source_s3_key(
630-
stamp=stamp, extension=serializer.validated_data["extension"]
631-
)
632-
633-
presigned_post = create_presigned_post(
634-
[
635-
["eq", "$Content-Type", serializer.validated_data["mimetype"]],
625+
presigned_post = (
626+
storage.get_initiate_backend().initiate_classroom_document_storage_upload(
627+
request,
628+
classroom_document,
636629
[
637-
"content-length-range",
638-
0,
639-
settings.CLASSROOM_DOCUMENT_SOURCE_MAX_SIZE,
630+
["eq", "$Content-Type", serializer.validated_data["mimetype"]],
631+
[
632+
"content-length-range",
633+
0,
634+
settings.CLASSROOM_DOCUMENT_SOURCE_MAX_SIZE,
635+
],
640636
],
641-
],
642-
{},
643-
key,
637+
)
644638
)
645639

646640
# Reset the upload state of the classroom document
@@ -651,6 +645,44 @@ def initiate_upload(self, request, pk=None, classroom_id=None):
651645

652646
return Response(presigned_post)
653647

648+
@action(methods=["post"], detail=True, url_path="upload-ended")
649+
# pylint: disable=unused-argument
650+
def upload_ended(self, request, pk=None, classroom_id=None):
651+
"""Notify the API that the classroom document upload has ended.
652+
653+
Calling the endpoint will update the upload state of the classroom document.
654+
The request should have a file_key in the body, which is the key of the
655+
uploaded file.
656+
657+
Parameters
658+
----------
659+
request : Type[django.http.request.HttpRequest]
660+
The request on the API endpoint
661+
pk: string
662+
The primary key of the classroom document
663+
664+
Returns
665+
-------
666+
Type[rest_framework.response.Response]
667+
HttpResponse with the serialized classroom document.
668+
"""
669+
# Ensure object exists and user has access to it
670+
classroom_document = self.get_object()
671+
672+
serializer = serializers.ClassroomDocumentUploadEndedSerializer(
673+
data=request.data, context={"obj": classroom_document}
674+
)
675+
serializer.is_valid(raise_exception=True)
676+
677+
file_key = serializer.validated_data["file_key"]
678+
# The file_key have the "classroom/{classroom_pk}/classroomdocument/{stamp}"
679+
# format
680+
stamp = file_key.split("/")[-1]
681+
682+
classroom_document.update_upload_state(READY, to_datetime(stamp))
683+
684+
return Response(serializer.data)
685+
654686
def perform_destroy(self, instance):
655687
"""Change the default document if the instance is the default one"""
656688

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 5.0.9 on 2025-04-02 15:05
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("bbb", "0023_classroom_infos"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="classroomdocument",
15+
name="storage_location",
16+
field=models.CharField(
17+
choices=[("AWS", "AWS"), ("SCW", "SCW")],
18+
default="SCW",
19+
help_text="Location used to store the classroom document",
20+
max_length=255,
21+
verbose_name="storage location",
22+
),
23+
),
24+
]

src/backend/marsha/bbb/models.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616

1717
from safedelete.managers import SafeDeleteManager
1818

19+
from marsha.core.defaults import (
20+
CLASSROOM_STORAGE_BASE_DIRECTORY,
21+
DELETED_STORAGE_BASE_DIRECTORY,
22+
SCW_S3,
23+
STORAGE_BASE_DIRECTORY,
24+
STORAGE_LOCATION_CHOICES,
25+
)
1926
from marsha.core.models import (
2027
BaseModel,
2128
Playlist,
@@ -329,6 +336,14 @@ class ClassroomDocument(UploadableFileMixin, BaseModel):
329336
help_text=_("is displayed by default in the classroom"),
330337
)
331338

339+
storage_location = models.CharField(
340+
max_length=255,
341+
verbose_name=_("storage location"),
342+
help_text=_("Location used to store the classroom document"),
343+
choices=STORAGE_LOCATION_CHOICES,
344+
default=SCW_S3,
345+
)
346+
332347
class Meta:
333348
"""Options for the ``ClassroomDocument`` model."""
334349

@@ -369,6 +384,37 @@ def get_source_s3_key(self, stamp=None, extension=None):
369384
stamp = stamp or to_timestamp(self.uploaded_on)
370385
return f"{self.classroom.pk}/classroomdocument/{self.pk}/{stamp}{extension}"
371386

387+
def get_storage_prefix(
388+
self,
389+
stamp=None,
390+
base_dir: STORAGE_BASE_DIRECTORY = CLASSROOM_STORAGE_BASE_DIRECTORY,
391+
):
392+
"""Compute the storage prefix for the classroom document.
393+
394+
Parameters
395+
----------
396+
stamp: Type[string]
397+
Passing a value for this argument will return the storage prefix for the
398+
classroom document assuming its active stamp is set to this value. This is
399+
useful to create an upload policy for this prospective version of the
400+
classroom, so that the client can upload the file to S3.
401+
402+
base: Type[STORAGE_BASE_DIRECTORY]
403+
The storage base directory. Defaults to Classroom. It will be used to
404+
compute the storage prefix.
405+
406+
Returns
407+
-------
408+
string
409+
The storage prefix for the classroom document, depending on the base directory passed.
410+
"""
411+
stamp = stamp or self.uploaded_on_stamp()
412+
base = base_dir
413+
if base == DELETED_STORAGE_BASE_DIRECTORY:
414+
base = f"{base}/{CLASSROOM_STORAGE_BASE_DIRECTORY}"
415+
416+
return f"{base}/{self.classroom.pk}/classroomdocument/{self.pk}/{stamp}"
417+
372418

373419
class ClassroomRecording(BaseModel):
374420
"""Model representing a recording in a classroom."""

src/backend/marsha/bbb/serializers.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,19 @@
2222
ClassroomSession,
2323
)
2424
from marsha.bbb.utils.bbb_utils import get_recording_url, get_url as get_document_url
25-
from marsha.core.defaults import CLASSROOM_RECORDINGS_KEY_CACHE, VOD_CONVERT
25+
from marsha.core.defaults import (
26+
CLASSROOM_RECORDINGS_KEY_CACHE,
27+
CLASSROOM_STORAGE_BASE_DIRECTORY,
28+
VOD_CONVERT,
29+
)
2630
from marsha.core.serializers import (
2731
BaseInitiateUploadSerializer,
2832
PlaylistLiteSerializer,
2933
ReadOnlyModelSerializer,
3034
UploadableFileWithExtensionSerializerMixin,
3135
VideoFromRecordingSerializer,
3236
)
37+
from marsha.core.utils import time_utils
3338

3439

3540
class ClassroomRecordingSerializer(ReadOnlyModelSerializer):
@@ -453,3 +458,28 @@ def validate(self, attrs):
453458
attrs["mimetype"] = mimetype
454459

455460
return attrs
461+
462+
463+
class ClassroomDocumentUploadEndedSerializer(serializers.Serializer):
464+
"""A serializer to validate data submitted on the UploadEnded API endpoint."""
465+
466+
file_key = serializers.CharField()
467+
468+
def validate_file_key(self, value):
469+
"""Check if the file_key is valid."""
470+
471+
stamp = value.split("/")[-1]
472+
473+
try:
474+
time_utils.to_datetime(stamp)
475+
except serializers.ValidationError as error:
476+
raise serializers.ValidationError("file_key is not valid") from error
477+
478+
if (
479+
self.context["obj"].get_storage_prefix(
480+
stamp, CLASSROOM_STORAGE_BASE_DIRECTORY
481+
)
482+
!= value
483+
):
484+
raise serializers.ValidationError("file_key is not valid")
485+
return value

0 commit comments

Comments
 (0)