Skip to content

Commit cbd41ec

Browse files
committed
✨(back) add support for storing deposited files on Scaleway S3
Leverage the existing django-storages backend to enable storing deposited files on Scaleway S3, alongside AWS S3. This dual-storage setup mirrors the approach used for classroom documents, allowing for a smooth transition away from AWS S3.
1 parent 48a66f6 commit cbd41ec

File tree

13 files changed

+710
-192
lines changed

13 files changed

+710
-192
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Versioning](https://semver.org/spec/v2.0.0.html).
1111
### Added
1212

1313
- Add support for storing classroom documents on Scaleway S3
14+
- Add support for storing deposited files on Scaleway S3
1415

1516
### Fixed
1617

src/backend/marsha/core/defaults.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,12 @@
181181
DELETED_STORAGE_BASE_DIRECTORY = "deleted"
182182
VOD_STORAGE_BASE_DIRECTORY = "vod"
183183
CLASSROOM_STORAGE_BASE_DIRECTORY = "classroom"
184+
FILE_DEPOSITORY_STORAGE_BASE_DIRECTORY = "filedepository"
184185

185186
STORAGE_BASE_DIRECTORY = (
186187
(TMP_STORAGE_BASE_DIRECTORY, _("tmp")),
187188
(VOD_STORAGE_BASE_DIRECTORY, _("VOD")),
188189
(CLASSROOM_STORAGE_BASE_DIRECTORY, _("Classroom")),
189190
(DELETED_STORAGE_BASE_DIRECTORY, _("deleted")),
191+
(FILE_DEPOSITORY_STORAGE_BASE_DIRECTORY, _("File Depository")),
190192
)

src/backend/marsha/core/storage/filesystem.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,38 @@ def initiate_classroom_document_storage_upload(request, obj, filename, condition
7777
)
7878
),
7979
}
80+
81+
82+
# pylint: disable=unused-argument
83+
def initiate_deposited_file_storage_upload(request, obj, filename, conditions):
84+
"""Get an upload policy for a deposited file.
85+
86+
Returns an upload policy for the filesystem backend.
87+
88+
Parameters
89+
----------
90+
request : Type[django.http.request.HttpRequest]
91+
The request on the API endpoint
92+
pk: string
93+
The primary key of the video
94+
95+
Returns
96+
-------
97+
Dictionary
98+
A dictionary with two elements: url and fields. Url is the url to post to. Fields is a
99+
dictionary filled with the form fields and respective values to use when submitting
100+
the post.
101+
102+
"""
103+
key = obj.get_storage_key(filename=filename)
104+
return {
105+
"fields": {
106+
"key": key,
107+
},
108+
"url": request.build_absolute_uri(
109+
reverse(
110+
"local-deposited_file-upload",
111+
args=[obj.pk],
112+
)
113+
),
114+
}

src/backend/marsha/core/storage/s3.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,29 @@ def initiate_classroom_document_storage_upload(request, obj, filename, condition
138138
S3FileStorage.bucket_name,
139139
"STORAGE_S3",
140140
)
141+
142+
143+
# pylint: disable=unused-argument
144+
def initiate_deposited_file_storage_upload(request, obj, filename, conditions):
145+
"""Get an upload policy for a deposited file.
146+
147+
The object must implement the get_storage_key method.
148+
Returns an upload policy to our storage S3 destination bucket.
149+
150+
Returns
151+
-------
152+
Dictionary
153+
A dictionary with two elements: url and fields. Url is the url to post to. Fields is a
154+
dictionary filled with the form fields and respective values to use when submitting
155+
the post.
156+
157+
"""
158+
key = obj.get_storage_key(filename=filename)
159+
160+
return create_presigned_post(
161+
conditions,
162+
{},
163+
key,
164+
S3FileStorage.bucket_name,
165+
"STORAGE_S3",
166+
)

src/backend/marsha/deposit/api.py

Lines changed: 67 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,10 @@
99
from rest_framework.decorators import action
1010
from rest_framework.response import Response
1111

12-
from marsha.core import defaults, permissions as core_permissions
12+
from marsha.core import defaults, permissions as core_permissions, storage
1313
from marsha.core.api import APIViewMixin, ObjectPkMixin, ObjectRelatedMixin
1414
from marsha.core.models import ADMINISTRATOR, LTI_ROLES, STUDENT
15-
from marsha.core.utils.s3_utils import create_presigned_post
16-
from marsha.core.utils.time_utils import to_timestamp
15+
from marsha.core.utils.time_utils import to_datetime, to_timestamp
1716
from marsha.deposit import permissions, serializers
1817
from marsha.deposit.defaults import LTI_ROUTE
1918
from marsha.deposit.forms import FileDepositoryForm
@@ -301,20 +300,20 @@ def initiate_upload(
301300
):
302301
"""Get an upload policy for a deposited file.
303302
304-
Calling the endpoint resets the upload state to `pending` and returns an upload policy to
305-
our AWS S3 source bucket.
303+
Calling the endpoint resets the upload state to `pending` and returns an upload
304+
policy to our S3 storage bucket.
306305
307306
Parameters
308307
----------
309308
request : Type[django.http.request.HttpRequest]
310309
The request on the API endpoint
311310
pk: string
312-
The primary key of the shared live media
311+
The primary key of the deposited file
313312
314313
Returns
315314
-------
316315
Type[rest_framework.response.Response]
317-
HttpResponse carrying the AWS S3 upload policy as a JSON object.
316+
HttpResponse carrying the S3 storage upload policy as a JSON object.
318317
319318
"""
320319
deposited_file = self.get_object() # check permissions first
@@ -326,20 +325,26 @@ def initiate_upload(
326325
if serializer.is_valid() is not True:
327326
return Response(serializer.errors, status=400)
328327

329-
now = timezone.now()
330-
stamp = to_timestamp(now)
331-
332-
key = deposited_file.get_source_s3_key(
333-
stamp=stamp, extension=serializer.validated_data["extension"]
334-
)
335-
336-
presigned_post = create_presigned_post(
337-
[
338-
["eq", "$Content-Type", serializer.validated_data["mimetype"]],
339-
["content-length-range", 0, settings.DEPOSITED_FILE_SOURCE_MAX_SIZE],
340-
],
341-
{},
342-
key,
328+
filename = serializer.validated_data["filename"]
329+
extension = serializer.validated_data["extension"]
330+
331+
if not filename.endswith(extension):
332+
filename = f"{filename}{extension}"
333+
334+
presigned_post = (
335+
storage.get_initiate_backend().initiate_deposited_file_storage_upload(
336+
request,
337+
deposited_file,
338+
filename,
339+
[
340+
["eq", "$Content-Type", serializer.validated_data["mimetype"]],
341+
[
342+
"content-length-range",
343+
0,
344+
settings.DEPOSITED_FILE_SOURCE_MAX_SIZE,
345+
],
346+
],
347+
)
343348
)
344349

345350
# Reset the upload state of the deposited file
@@ -349,3 +354,44 @@ def initiate_upload(
349354
)
350355

351356
return Response(presigned_post)
357+
358+
@action(methods=["post"], detail=True, url_path="upload-ended")
359+
# pylint: disable=unused-argument
360+
def upload_ended(
361+
self,
362+
request,
363+
pk=None,
364+
filedepository_id=None,
365+
):
366+
"""Notify the API that the deposited file upload has ended.
367+
368+
Calling the endpoint will update the upload state of the deposited file.
369+
The request should have a file_key in the body, which is the key of the
370+
uploaded file.
371+
372+
Parameters
373+
----------
374+
request : Type[django.http.request.HttpRequest]
375+
The request on the API endpoint
376+
pk: string
377+
The primary key of the deposited file
378+
379+
Returns
380+
-------
381+
Type[rest_framework.response.Response]
382+
HttpResponse with the serialized deposited file.
383+
"""
384+
deposited_file = self.get_object() # check permissions first
385+
386+
serializer = serializers.DepositedFileUploadEndedSerializer(
387+
data=request.data, context={"obj": deposited_file}
388+
)
389+
390+
serializer.is_valid(raise_exception=True)
391+
392+
now = timezone.now()
393+
stamp = to_timestamp(now)
394+
395+
deposited_file.update_upload_state(defaults.READY, to_datetime(stamp))
396+
397+
return Response(serializer.data)
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-30 07:19
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("deposit", "0005_alter_depositedfile_options"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="depositedfile",
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 deposited file",
20+
max_length=255,
21+
verbose_name="storage location",
22+
),
23+
),
24+
]

src/backend/marsha/deposit/models.py

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@
1010
from django.db import models
1111
from django.utils.translation import gettext_lazy as _
1212

13+
from marsha.core.defaults import (
14+
DELETED_STORAGE_BASE_DIRECTORY,
15+
FILE_DEPOSITORY_STORAGE_BASE_DIRECTORY,
16+
SCW_S3,
17+
STORAGE_BASE_DIRECTORY,
18+
STORAGE_LOCATION_CHOICES,
19+
)
1320
from marsha.core.models import BaseModel, Playlist, UploadableFileMixin
14-
from marsha.core.utils.time_utils import to_timestamp
1521

1622

1723
logger = logging.getLogger(__name__)
@@ -152,6 +158,14 @@ class DepositedFile(UploadableFileMixin, BaseModel):
152158
verbose_name=_("read"),
153159
)
154160

161+
storage_location = models.CharField(
162+
max_length=255,
163+
verbose_name=_("storage location"),
164+
help_text=_("Location used to store the deposited file"),
165+
choices=STORAGE_LOCATION_CHOICES,
166+
default=SCW_S3,
167+
)
168+
155169
class Meta:
156170
"""Options for the ``DepositedFile`` model."""
157171

@@ -160,43 +174,33 @@ class Meta:
160174
verbose_name = _("Deposited file")
161175
verbose_name_plural = _("Deposited files")
162176

163-
def get_source_s3_key(self, stamp=None, extension=None):
164-
"""Compute the S3 key in the source bucket.
165-
166-
It is built from the file deposit ID + ID of the deposited file + version stamp.
177+
def get_storage_key(
178+
self,
179+
filename,
180+
base_dir: STORAGE_BASE_DIRECTORY = FILE_DEPOSITORY_STORAGE_BASE_DIRECTORY,
181+
):
182+
"""Compute the storage key for the classroom document.
167183
168184
Parameters
169185
----------
170-
stamp: Type[string]
171-
Passing a value for this argument will return the source S3 key for the deposited file
172-
assuming its active stamp is set to this value. This is useful to create an
173-
upload policy for this prospective version of the track, so that the client can
174-
upload the file to S3 and the confirmation lambda can set the `uploaded_on` field
175-
to this value only after the file upload and processing is successful.
176-
177-
178-
extension: Type[string]
179-
The extension used by the uploaded media. This extension is added at the end of the key
180-
to keep a record of the extension. We will use it in the update-state endpoint to
181-
record it in the database.
186+
filename: Type[string]
187+
The filename of the uploaded media. For classroom documents, the filename is
188+
directly set into the key.
189+
base: Type[STORAGE_BASE_DIRECTORY]
190+
The storage base directory. Defaults to Classroom. It will be used to
191+
compute the storage key.
182192
183193
Returns
184194
-------
185195
string
186-
The S3 key for the deposited file in the source bucket, where uploaded files are
187-
stored before they are converted and copied to the destination bucket.
188-
196+
The storage key for the classroom document, depending on the base directory
197+
passed.
189198
"""
190-
# We don't want to deal with None value so we set it with an empty string
191-
extension = extension or ""
192-
193-
# We check if the extension starts with a leading dot or not. If it's not the case we add
194-
# it at the beginning of the string
195-
if extension and not extension.startswith("."):
196-
extension = "." + extension
199+
base = base_dir
200+
if base == DELETED_STORAGE_BASE_DIRECTORY:
201+
base = f"{base}/{FILE_DEPOSITORY_STORAGE_BASE_DIRECTORY}"
197202

198-
stamp = stamp or to_timestamp(self.uploaded_on)
199-
return f"{self.file_depository.pk}/depositedfile/{self.pk}/{stamp}{extension}"
203+
return f"{base}/{self.file_depository.pk}/depositedfile/{self.pk}/{filename}"
200204

201205
def update_upload_state(self, upload_state, uploaded_on, **extra_parameters):
202206
"""Manage upload state.

0 commit comments

Comments
 (0)