Skip to content

Commit 37888b3

Browse files
feat: store blobs+set mtime in RFC publish API (#10260)
* feat: set mtime for RFC pub files * chore: add rfc storage * refactor: destination helper is fs-specific * feat: RFC files->blobstore in publish API * test: test blob writing * chore: remove completed todo comment
1 parent 337a231 commit 37888b3

File tree

3 files changed

+111
-11
lines changed

3 files changed

+111
-11
lines changed

ietf/api/serializers_rpc.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from django.db import transaction
77
from django.urls import reverse as urlreverse
8+
from django.utils import timezone
89
from drf_spectacular.types import OpenApiTypes
910
from drf_spectacular.utils import extend_schema_field
1011
from rest_framework import serializers
@@ -571,6 +572,12 @@ class RfcFileSerializer(serializers.Serializer):
571572
"file types, but filenames are otherwise ignored."
572573
),
573574
)
575+
mtime = serializers.DateTimeField(
576+
required=False,
577+
default=timezone.now,
578+
default_timezone=datetime.UTC,
579+
help_text="Modification timestamp to apply to uploaded files",
580+
)
574581
replace = serializers.BooleanField(
575582
required=False,
576583
default=False,

ietf/api/tests_views_rpc.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.test.utils import override_settings
1111
from django.urls import reverse as urlreverse
1212

13+
from ietf.blobdb.models import Blob
1314
from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFactory
1415
from ietf.doc.models import RelatedDocument, Document
1516
from ietf.group.factories import RoleFactory, GroupFactory
@@ -259,6 +260,31 @@ def _valid_post_data():
259260
)
260261
self.assertEqual(r.status_code, 400)
261262

263+
# Put a file in the way. Post should fail because replace = False
264+
file_in_the_way = (rfc_path / f"rfc{unused_rfc_number}.txt")
265+
file_in_the_way.touch()
266+
r = self.client.post(
267+
url,
268+
_valid_post_data(),
269+
format="multipart",
270+
headers={"X-Api-Key": "valid-token"},
271+
)
272+
self.assertEqual(r.status_code, 409) # conflict
273+
file_in_the_way.unlink()
274+
275+
# Put a blob in the way. Post should fail because replace = False
276+
blob_in_the_way = Blob.objects.create(
277+
bucket="rfc", name=f"txt/rfc{unused_rfc_number}.txt", content=b""
278+
)
279+
r = self.client.post(
280+
url,
281+
_valid_post_data(),
282+
format="multipart",
283+
headers={"X-Api-Key": "valid-token"},
284+
)
285+
self.assertEqual(r.status_code, 409) # conflict
286+
blob_in_the_way.delete()
287+
262288
# valid post
263289
r = self.client.post(
264290
url,
@@ -267,21 +293,41 @@ def _valid_post_data():
267293
headers={"X-Api-Key": "valid-token"},
268294
)
269295
self.assertEqual(r.status_code, 200)
270-
for suffix in [".xml", ".txt", ".html", ".pdf", ".json"]:
296+
for extension in ["xml", "txt", "html", "pdf", "json"]:
297+
filename = f"rfc{unused_rfc_number}.{extension}"
271298
self.assertEqual(
272-
(rfc_path / f"rfc{unused_rfc_number}")
273-
.with_suffix(suffix)
299+
(rfc_path / filename)
274300
.read_text(),
275-
f"This is {suffix}",
276-
f"{suffix} file should contain the expected content",
301+
f"This is .{extension}",
302+
f"{extension} file should contain the expected content",
303+
)
304+
self.assertEqual(
305+
bytes(
306+
Blob.objects.get(
307+
bucket="rfc", name=f"{extension}/{filename}"
308+
).content
309+
),
310+
f"This is .{extension}".encode("utf-8"),
311+
f"{extension} blob should contain the expected content",
277312
)
313+
# special case for notprepped
314+
notprepped_fn = f"rfc{unused_rfc_number}.notprepped.xml"
278315
self.assertEqual(
279316
(
280-
rfc_path / "prerelease" / f"rfc{unused_rfc_number}.notprepped.xml"
317+
rfc_path / "prerelease" / notprepped_fn
281318
).read_text(),
282319
"This is .notprepped.xml",
283320
".notprepped.xml file should contain the expected content",
284321
)
322+
self.assertEqual(
323+
bytes(
324+
Blob.objects.get(
325+
bucket="rfc", name=f"notprepped/{notprepped_fn}"
326+
).content
327+
),
328+
b"This is .notprepped.xml",
329+
".notprepped.xml blob should contain the expected content",
330+
)
285331

286332
# re-post with replace = False should now fail
287333
r = self.client.post(

ietf/api/views_rpc.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Copyright The IETF Trust 2023-2026, All Rights Reserved
2+
import os
23
import shutil
34
from pathlib import Path
45
from tempfile import TemporaryDirectory
@@ -35,6 +36,7 @@
3536
)
3637
from ietf.doc.models import Document, DocHistory, RfcAuthor
3738
from ietf.doc.serializers import RfcAuthorSerializer
39+
from ietf.doc.storage_utils import remove_from_storage, store_file, exists_in_storage
3840
from ietf.person.models import Email, Person
3941

4042

@@ -366,8 +368,8 @@ class RfcPubFilesView(APIView):
366368
api_key_endpoint = "ietf.api.views_rpc"
367369
parser_classes = [parsers.MultiPartParser]
368370

369-
def _destination(self, filename: str | Path) -> Path:
370-
"""Destination for an uploaded RFC file
371+
def _fs_destination(self, filename: str | Path) -> Path:
372+
"""Destination for an uploaded RFC file in the filesystem
371373
372374
Strips any path components in filename and returns an absolute Path.
373375
"""
@@ -378,6 +380,23 @@ def _destination(self, filename: str | Path) -> Path:
378380
return rfc_path / "prerelease" / filename.name
379381
return rfc_path / filename.name
380382

383+
def _blob_destination(self, filename: str | Path) -> str:
384+
"""Destination name for an uploaded RFC file in the blob store
385+
386+
Strips any path components in filename and returns an absolute Path.
387+
"""
388+
filename = Path(filename) # could potentially have directory components
389+
extension = "".join(filename.suffixes)
390+
if extension == ".notprepped.xml":
391+
file_type = "notprepped"
392+
elif extension[0] == ".":
393+
file_type = extension[1:]
394+
else:
395+
raise serializers.ValidationError(
396+
f"Extension does not begin with '.'!? ({filename})",
397+
)
398+
return f"{file_type}/{filename.name}"
399+
381400
@extend_schema(
382401
operation_id="upload_rfc_files",
383402
summary="Upload files for a published RFC",
@@ -394,10 +413,17 @@ def post(self, request):
394413
uploaded_files = serializer.validated_data["contents"] # list[UploadedFile]
395414
replace = serializer.validated_data["replace"]
396415
dest_stem = f"rfc{rfc.rfc_number}"
416+
mtime = serializer.validated_data["mtime"]
417+
mtimestamp = mtime.timestamp()
418+
blob_kind = "rfc"
397419

398420
# List of files that might exist for an RFC
399421
possible_rfc_files = [
400-
self._destination(dest_stem + ext)
422+
self._fs_destination(dest_stem + ext)
423+
for ext in serializer.allowed_extensions
424+
]
425+
possible_rfc_blobs = [
426+
self._blob_destination(dest_stem + ext)
401427
for ext in serializer.allowed_extensions
402428
]
403429
if not replace:
@@ -408,6 +434,14 @@ def post(self, request):
408434
"File(s) already exist for this RFC",
409435
code="files-exist",
410436
)
437+
for possible_existing_blob in possible_rfc_blobs:
438+
if exists_in_storage(
439+
kind=blob_kind, name=possible_existing_blob
440+
):
441+
raise Conflict(
442+
"Blob(s) already exist for this RFC",
443+
code="blobs-exist",
444+
)
411445

412446
with TemporaryDirectory() as tempdir:
413447
# Save files in a temporary directory. Use the uploaded filename
@@ -421,14 +455,27 @@ def post(self, request):
421455
with tempfile_path.open("wb") as dest:
422456
for chunk in upfile.chunks():
423457
dest.write(chunk)
458+
os.utime(tempfile_path, (mtimestamp, mtimestamp))
424459
files_to_move.append(tempfile_path)
425460
# copy files to final location, removing any existing ones first if the
426461
# remove flag was set
427462
if replace:
428463
for possible_existing_file in possible_rfc_files:
429464
possible_existing_file.unlink(missing_ok=True)
465+
for possible_existing_blob in possible_rfc_blobs:
466+
remove_from_storage(
467+
blob_kind, possible_existing_blob, warn_if_missing=False
468+
)
430469
for ftm in files_to_move:
431-
shutil.move(ftm, self._destination(ftm))
432-
# todo store in blob storage as well (need a bucket for RFCs)
470+
with ftm.open("rb") as f:
471+
store_file(
472+
kind=blob_kind,
473+
name=self._blob_destination(ftm),
474+
file=f,
475+
doc_name=rfc.name,
476+
doc_rev=rfc.rev, # expect None, but match whatever it is
477+
mtime=mtime,
478+
)
479+
shutil.move(ftm, self._fs_destination(ftm))
433480

434481
return Response(NotificationAckSerializer().data)

0 commit comments

Comments
 (0)