This is an automated email from the ASF dual-hosted git repository.
yasithdev pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airavata-portals.git
The following commit(s) were added to refs/heads/main by this push:
new 691879c8f feat(portal): de-Thrift user/experiment storage listing to
gRPC (Track D, D4.3) (#190)
691879c8f is described below
commit 691879c8f7b951f482f3a471bd5485fabf209f00
Author: Yasith Jayawardana <[email protected]>
AuthorDate: Tue Jun 9 02:26:38 2026 -0400
feat(portal): de-Thrift user/experiment storage listing to gRPC (Track D,
D4.3) (#190)
Repoint the storage path views and the create-user-storage-dir signal from
the
legacy airavata_django_portal_sdk user_storage helpers to the gRPC storage
facade:
- UserStoragePathView get/post/put/delete + ExperimentStoragePathView →
storage.dir_exists/list_dir/get_file_metadata/create_dir/delete_dir/delete_file
(+ _storage_upload_and_register for uploads). New _user_storage_path
resolves a
request path to the absolute ~/-prefixed path the facade expects,
including
experiment-relative paths (via the experiment data dir).
- New grpc_adapters.user_storage_file / user_storage_directory map the gRPC
FileMetadataResponse
(name/path/size/modified_time/content_type/data_product_uri,
populated server-side by apache/airavata#652) to the dicts the storage
serializers read.
- UserStorageFileSerializer.downloadURL now builds the portal download-file
URL
directly from the file's data product URI;
UserHasWriteAccessToPathSerializer
drops the remote-API user_storage.listdir branch (the gRPC path talks to
the
backend directly, not a remote portal proxy).
- signals.create_user_storage_dir → storage.create_dir/create_symlink.
- The deprecated download_file view redirects to the gRPC download-file
endpoint.
- Drop the now-unused user_storage (and queue_settings_calculators) imports
from
views.py/serializers.py.
REST/JSON contract unchanged. Depends on apache/airavata#652 (per-file
modified-time/content-type/data-product-uri in the listing + SFTP file
delete).
Validated live against the running backend: UserStoragePathView GET lists
~/tmp
(200) with each file rendering name/size/createdTime/modifiedTime/mimeType/
dataProductURI/downloadURL/userHasWriteAccess; upload (create dir +
register),
content overwrite, and DELETE (204, file removed) all round-trip; manage.py
check
clean.
---
.../django_airavata/apps/api/grpc_adapters.py | 45 +++++++
.../django_airavata/apps/api/serializers.py | 39 ++----
.../django_airavata/apps/api/signals.py | 11 +-
.../django_airavata/apps/api/views.py | 146 ++++++++++++++-------
4 files changed, 160 insertions(+), 81 deletions(-)
diff --git a/airavata-django-portal/django_airavata/apps/api/grpc_adapters.py
b/airavata-django-portal/django_airavata/apps/api/grpc_adapters.py
index 9d9111946..29b08f85c 100644
--- a/airavata-django-portal/django_airavata/apps/api/grpc_adapters.py
+++ b/airavata-django-portal/django_airavata/apps/api/grpc_adapters.py
@@ -1159,3 +1159,48 @@ def grid_ftp_data_movement(pb):
'SECURITY_PROTOCOL_'),
gridFTPEndPoints=list(pb.grid_ftp_end_points),
)
+
+
+# --- User storage file/directory listings -----------------------------------
+# The storage serializers (UserStorageFileSerializer /
UserStorageDirectorySerializer)
+# read plain dicts keyed the way the legacy user_storage.listdir produced. The
gRPC
+# FileMetadataResponse carries name/path/size/modified_time(epoch
ms)/content_type/
+# data_product_uri; map it to that dict shape. ``relative_path`` overrides the
path the
+# serializer reports (e.g. experiment-dir listings expose a path relative to
the data dir).
+
+def _epoch_millis_to_datetime(value):
+ """Epoch milliseconds -> aware UTC datetime, or None when unset (0)."""
+ if not value:
+ return None
+ import datetime
+ return datetime.datetime.fromtimestamp(value / 1000,
tz=datetime.timezone.utc)
+
+
+def user_storage_file(pb, relative_path=None):
+ """gRPC ``FileMetadataResponse`` (a file) -> UserStorageFileSerializer
dict."""
+ modified = _epoch_millis_to_datetime(pb.modified_time)
+ return {
+ 'name': pb.name,
+ 'path': relative_path if relative_path is not None else pb.path,
+ 'data-product-uri': pb.data_product_uri or None,
+ # The adaptor exposes only a modified time; reuse it for created time
so
+ # the serializer's createdTime/modifiedTime both render (SFTP has no
ctime).
+ 'created_time': modified,
+ 'modified_time': modified,
+ 'mime_type': pb.content_type or None,
+ 'size': pb.size,
+ 'hidden': False,
+ }
+
+
+def user_storage_directory(pb, relative_path=None):
+ """gRPC ``FileMetadataResponse`` (a directory) ->
UserStorageDirectorySerializer dict."""
+ modified = _epoch_millis_to_datetime(pb.modified_time)
+ return {
+ 'name': pb.name,
+ 'path': relative_path if relative_path is not None else pb.path,
+ 'created_time': modified,
+ 'modified_time': modified,
+ 'size': pb.size,
+ 'hidden': False,
+ }
diff --git a/airavata-django-portal/django_airavata/apps/api/serializers.py
b/airavata-django-portal/django_airavata/apps/api/serializers.py
index 9d22aa40f..257e7ce35 100644
--- a/airavata-django-portal/django_airavata/apps/api/serializers.py
+++ b/airavata-django-portal/django_airavata/apps/api/serializers.py
@@ -67,9 +67,7 @@ from airavata.model.workspace.ttypes import (
Project
)
from airavata_django_portal_sdk import (
- experiment_util,
- queue_settings_calculators,
- user_storage
+ experiment_util
)
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -1871,26 +1869,8 @@ class
UserHasWriteAccessToPathSerializer(serializers.Serializer):
def get_userHasWriteAccess(self, instance):
request = self.context['request']
- # Special handling when using remote API to access user data storage
- if hasattr(settings, 'GATEWAY_DATA_STORE_REMOTE_API'):
- if "userHasWriteAccess" in instance:
- return instance["userHasWriteAccess"]
- elif instance.get("isDir", False):
- path = Path(instance.get("path", ""))
- if path != Path(""):
- # get parent directory listing and use that to figure out
if
- # there is write access to this directory
- directories, _ = user_storage.listdir(request, path.parent)
- for d in directories:
- if Path(d["path"]) == path:
- return d.get("userHasWriteAccess", False)
- return False
- else:
- # User always has write access on home directory
- return True
- else:
- return False
-
+ if "userHasWriteAccess" in instance:
+ return instance["userHasWriteAccess"]
is_shared_path = view_utils.is_shared_path(instance["path"])
if is_shared_path:
return request.is_gateway_admin
@@ -1909,9 +1889,18 @@ class
UserStorageFileSerializer(UserHasWriteAccessToPathSerializer):
hidden = serializers.BooleanField()
def get_downloadURL(self, file):
- """Getter for downloadURL field."""
+ """Lazy portal URL to the byte-streaming download endpoint for this
file.
+
+ Returns None when the file has no data product URI; resolving the
bytes is
+ deferred to the endpoint, so this getter makes no backend call.
+ """
request = self.context['request']
- return user_storage.get_lazy_download_url(request,
data_product_uri=file['data-product-uri'])
+ data_product_uri = file.get('data-product-uri')
+ if not data_product_uri:
+ return None
+ base = request.build_absolute_uri(
+ reverse('django_airavata_api:download-file'))
+ return base + '?data-product-uri=' + quote(data_product_uri)
class UserStorageDirectorySerializer(UserHasWriteAccessToPathSerializer):
diff --git a/airavata-django-portal/django_airavata/apps/api/signals.py
b/airavata-django-portal/django_airavata/apps/api/signals.py
index ceab29b86..62d2a4363 100644
--- a/airavata-django-portal/django_airavata/apps/api/signals.py
+++ b/airavata-django-portal/django_airavata/apps/api/signals.py
@@ -2,7 +2,6 @@
import logging
-from airavata_django_portal_sdk import user_storage
from django.conf import settings
from django.contrib.auth.signals import user_logged_in
from django.dispatch import Signal, receiver
@@ -18,12 +17,12 @@ user_added_to_group = Signal()
# Receivers
@receiver(user_logged_in)
def create_user_storage_dir(sender, request, user, **kwargs):
- """Create user's home direct in gateway storage."""
- path = ""
- if not user_storage.dir_exists(request, path):
- user_storage.create_user_dir(request, path)
+ """Create user's home directory in gateway storage (gRPC storage
facade)."""
+ storage = request.airavata.storage
+ if not storage.dir_exists("~/"):
+ storage.create_dir("~/")
log.info("Created home directory for user {}".format(user.username))
if hasattr(settings, 'GATEWAY_DATA_SHARED_DIRECTORIES'):
for name, entry in settings.GATEWAY_DATA_SHARED_DIRECTORIES.items():
- user_storage.create_symlink(request, entry['path'], name)
+ storage.create_symlink(entry['path'], "~/" + name)
diff --git a/airavata-django-portal/django_airavata/apps/api/views.py
b/airavata-django-portal/django_airavata/apps/api/views.py
index fd49b2a54..79a2ef19c 100644
--- a/airavata-django-portal/django_airavata/apps/api/views.py
+++ b/airavata-django-portal/django_airavata/apps/api/views.py
@@ -5,6 +5,7 @@ import logging
import os
import warnings
from datetime import datetime, timedelta
+from urllib.parse import quote
from airavata.model.appcatalog.computeresource.ttypes import (
CloudJobSubmission,
@@ -33,8 +34,7 @@ from airavata.model.group.ttypes import ResourcePermissionType
from airavata.model.user.ttypes import Status
from airavata_django_portal_sdk import (
experiment_util,
- queue_settings_calculators,
- user_storage
+ queue_settings_calculators
)
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -88,11 +88,12 @@ log = logging.getLogger(__name__)
def _storage_upload_and_register(request, dir_path, uploaded_file, name=None,
- content_type=None):
+ content_type=None, experiment_id=None):
"""Upload a file to user storage and register a data product for it (gRPC).
Writes the bytes via the ``storage`` facade (the path is the full file
path,
- ``~/``-prefixed so the backend resolves it against the storage root), then
+ ``~/``-prefixed so the backend resolves it against the storage root, or
+ relative to the experiment data dir when ``experiment_id`` is given), then
registers a data product via the ``research`` facade so the file has a
canonical product URI. Returns the registered data product adapted to the
``DataProductSerializer`` shape. Replaces the legacy
@@ -101,8 +102,9 @@ def _storage_upload_and_register(request, dir_path,
uploaded_file, name=None,
"""
storage = request.airavata.storage
name = name or os.path.basename(getattr(uploaded_file, 'name', '') or '')
- # Full file path, ~/-prefixed so resolvePath expands it to the storage
root.
- upload_path = "~/" + os.path.join(dir_path, name).lstrip("/")
+ # Full file path resolved against the storage root (or experiment data
dir).
+ upload_path = _user_storage_path(
+ os.path.join(dir_path, name), experiment_id, request)
content = uploaded_file.read()
storage.upload_file(
path=upload_path, content=content, name=name,
@@ -969,10 +971,12 @@ def tus_upload_finish(request):
@api_view()
def download_file(request):
# TODO: remove this deprecated view
- warnings.warn("download_file view has moved to SDK", DeprecationWarning)
- # redirect to /sdk/download
+ warnings.warn("download_file view is deprecated; use 'download-file'",
DeprecationWarning)
+ # Redirect to the gRPC byte-streaming download endpoint.
data_product_uri = request.GET.get('data-product-uri', '')
- return redirect(user_storage.get_download_url(request,
data_product_uri=data_product_uri))
+ return redirect(
+
request.build_absolute_uri(reverse('django_airavata_api:download-file'))
+ + '?data-product-uri=' + quote(data_product_uri))
@api_view()
@@ -1659,6 +1663,28 @@ class ParserViewSet(mixins.CreateModelMixin,
self.request.airavata.research.save_parser(grpc_requests.parser(parser))
+def _user_storage_path(path, experiment_id=None, request=None):
+ """Resolve a user-storage path to the absolute, ``~/``-prefixed path the
gRPC
+ storage facade expects.
+
+ A bare relative path is taken relative to the user's storage root (``~/``).
+ When ``experiment_id`` is given, the path is relative to that experiment's
+ data directory (resolved via the experiment's userConfigurationData).
+ """
+ rel = (path or "").lstrip("/")
+ if experiment_id:
+ experiment = grpc_adapters.experiment(
+ request.airavata.research.get_experiment(experiment_id))
+ data_dir = (experiment.userConfigurationData.experimentDataDir
+ if experiment.userConfigurationData else None) or ""
+ base = data_dir.rstrip("/")
+ full = base + ("/" + rel if rel else "")
+ return full if (full.startswith("/") or full.startswith("~/")) else
"~/" + full
+ if rel.startswith("~"):
+ return rel
+ return "~/" + rel
+
+
class UserStoragePathView(APIView):
serializer_class = serializers.UserStoragePathSerializer
permission_classes = (IsAuthenticated, UserStorageSharedDirPermission)
@@ -1672,19 +1698,18 @@ class UserStoragePathView(APIView):
def post(self, request, path="/", format=None):
path = request.data.get('path', path)
experiment_id = request.data.get('experiment-id')
- if not user_storage.dir_exists(request, path,
experiment_id=experiment_id):
- _, resource_path = user_storage.create_user_dir(request, path,
experiment_id=experiment_id)
- # create_user_dir may create the directory with a different name
- # than requested, for example, converting spaces to underscores, so
- # use as the path the path that is returned by create_user_dir
- path = resource_path
+ storage = request.airavata.storage
+ resolved = _user_storage_path(path, experiment_id, request)
+ if not storage.dir_exists(resolved):
+ storage.create_dir(resolved)
data_product = None
# Handle direct upload
if 'file' in request.FILES:
user_file = request.FILES['file']
- data_product = user_storage.save(
- request, path, user_file, content_type=user_file.content_type,
+ data_product = _storage_upload_and_register(
+ request, path, user_file, name=user_file.name,
+ content_type=user_file.content_type,
experiment_id=experiment_id)
# Handle a tus upload
elif 'uploadURL' in request.POST:
@@ -1692,26 +1717,26 @@ class UserStoragePathView(APIView):
def save_file(file_path, file_name, file_type):
with open(file_path, 'rb') as uploaded_file:
- return user_storage.save(request, path, uploaded_file,
- name=file_name,
content_type=file_type,
- experiment_id=experiment_id)
+ return _storage_upload_and_register(
+ request, path, uploaded_file, name=file_name,
+ content_type=file_type, experiment_id=experiment_id)
data_product = tus.save_tus_upload(uploadURL, save_file)
return self._create_response(request, path, uploaded=data_product,
experiment_id=experiment_id)
- # Accept wither to replace file or to replace file content text.
+ # Accept either to replace file or to replace file content text.
def put(self, request, path="/", format=None):
path = request.POST.get('path', path)
# Replace the file if the request has a file upload.
if 'file' in request.FILES:
self.delete(request=request, path=path, format=format)
dir_path, file_name = os.path.split(path)
- self.post(request=request, path=dir_path, format=format,
file_name=file_name)
+ self.post(request=request, path=dir_path, format=format)
# Replace only the file content if the request body has the
`fileContentText`
elif request.data and "fileContentText" in request.data:
- user_storage.update_file_content(
- request=request,
- path=path,
- fileContentText=request.data["fileContentText"])
+ request.airavata.storage.upload_file(
+ path=_user_storage_path(path),
+ content=request.data["fileContentText"].encode("utf-8"),
+ name=os.path.basename(path))
else:
return Response(status=status.HTTP_400_BAD_REQUEST)
@@ -1720,20 +1745,25 @@ class UserStoragePathView(APIView):
def delete(self, request, path="/", format=None):
path = request.data.get('path', path)
experiment_id = request.data.get('experiment-id')
- if user_storage.dir_exists(request, path, experiment_id=experiment_id):
- user_storage.delete_dir(request, path, experiment_id=experiment_id)
+ storage = request.airavata.storage
+ resolved = _user_storage_path(path, experiment_id, request)
+ if storage.dir_exists(resolved):
+ storage.delete_dir(resolved)
else:
- user_storage.delete_user_file(request, path,
experiment_id=experiment_id)
+ storage.delete_file(resolved)
return Response(status=204)
def _create_response(self, request, path, uploaded=None,
experiment_id=None):
- if user_storage.dir_exists(request, path, experiment_id=experiment_id):
- directories, files = user_storage.listdir(request, path,
experiment_id=experiment_id)
+ storage = request.airavata.storage
+ resolved = _user_storage_path(path, experiment_id, request)
+ if storage.dir_exists(resolved):
+ listing = storage.list_dir(resolved)
data = {
'isDir': True,
- 'directories': directories,
- 'files': files
+ 'directories': [
+ grpc_adapters.user_storage_directory(d) for d in
listing.directories],
+ 'files': [grpc_adapters.user_storage_file(f) for f in
listing.files],
}
if uploaded is not None:
data['uploaded'] = uploaded
@@ -1743,7 +1773,7 @@ class UserStoragePathView(APIView):
data, context={'request': request})
return Response(serializer.data)
else:
- file = user_storage.get_file_metadata(request, path,
experiment_id=experiment_id)
+ file =
grpc_adapters.user_storage_file(storage.get_file_metadata(resolved))
data = {
'isDir': False,
'directories': [],
@@ -1774,23 +1804,39 @@ class ExperimentStoragePathView(APIView):
return self._create_response(request, experiment_id, path)
def _create_response(self, request, experiment_id, path):
- if user_storage.experiment_dir_exists(request, experiment_id, path):
- directories, files = user_storage.list_experiment_dir(request,
experiment_id, path)
-
- def add_expid(d):
- d['experiment_id'] = experiment_id
- return d
- data = {
- 'isDir': True,
- 'directories': map(add_expid, directories),
- 'files': map(add_expid, files)
- }
- data['parts'] = self._split_path(path)
- serializer = self.serializer_class(
- data, context={'request': request})
- return Response(serializer.data)
- else:
+ storage = request.airavata.storage
+ resolved = _user_storage_path(path, experiment_id, request)
+ if not storage.dir_exists(resolved):
raise Http404(f"Path '{path}' does not exist for {experiment_id}")
+ listing = storage.list_dir(resolved)
+
+ def rel(entry_path):
+ # Expose the path relative to the experiment data dir, as the
legacy
+ # list_experiment_dir did (resolved is the absolute experiment
path).
+ base = resolved.rstrip("/")
+ p = entry_path
+ if p.startswith(base + "/"):
+ return p[len(base) + 1:]
+ return os.path.basename(p)
+
+ def add_expid(d):
+ d['experiment_id'] = experiment_id
+ return d
+ data = {
+ 'isDir': True,
+ 'directories': [
+ add_expid(grpc_adapters.user_storage_directory(
+ d, relative_path=os.path.join(path, rel(d.path)) if path
else rel(d.path)))
+ for d in listing.directories],
+ 'files': [
+ add_expid(grpc_adapters.user_storage_file(
+ f, relative_path=os.path.join(path, rel(f.path)) if path
else rel(f.path)))
+ for f in listing.files],
+ }
+ data['parts'] = self._split_path(path)
+ serializer = self.serializer_class(
+ data, context={'request': request})
+ return Response(serializer.data)
def _split_path(self, path):
head, tail = os.path.split(path)