Skip to content

Commit 434cd27

Browse files
jbernal0019Jennings Zhang
andauthored
Implement ChRIS folders and links (#545)
* Add a new ChrisFolder model and update the file models accordingly * Reimplement the file browser using the new underlying ChrisFolder model * Refactor automated test to work with the new underlying ChrisFolder model * Update chrisomatic to support userfiles change * Add ChRIS links models, serializers and views * Create ChRIS links when handling unextracted path parameters * Find all file paths under a ChRIS link's pointed path * Fix bug when multiple input directories are passed to create_zip_file * Make output files consistent across different storage environments * Add output_dir to the pfcon request parameters for any pfcon in network * Fix bug with output dir creation for fslink * Add fslink storage environment * Fix bug in the filebrowser associated with the SERVICES top level folder and add unit tests * Add more automated tests to the filebrowser app --------- Co-authored-by: Jennings Zhang <[email protected]>
1 parent 01b2928 commit 434cd27

File tree

71 files changed

+3428
-1388
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+3428
-1388
lines changed

chris_backend/config/settings/local.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484

8585
# Storage Settings
8686
STORAGE_ENV = os.getenv('STORAGE_ENV', 'swift')
87-
if STORAGE_ENV not in ('swift', 'filesystem'):
87+
if STORAGE_ENV not in ('swift', 'fslink', 'filesystem'):
8888
raise ImproperlyConfigured(f"Unsupported value '{STORAGE_ENV}' for STORAGE_ENV")
8989

9090
STORAGES['default'] = {'BACKEND': 'swift.storage.SwiftStorage'}
@@ -96,7 +96,7 @@
9696
'key': SWIFT_KEY,
9797
'authurl': SWIFT_AUTH_URL}
9898
MEDIA_ROOT = None
99-
if STORAGE_ENV == 'filesystem':
99+
if STORAGE_ENV in ('fslink', 'filesystem'):
100100
STORAGES['default'] = {'BACKEND': 'django.core.files.storage.FileSystemStorage'}
101101
MEDIA_ROOT = '/var/chris' # local filesystem storage settings
102102

chris_backend/config/settings/production.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def get_secret(setting, secret_type=env):
5555
# STORAGE CONFIGURATION
5656
# ------------------------------------------------------------------------------
5757
STORAGE_ENV = get_secret('STORAGE_ENV')
58-
if STORAGE_ENV not in ('swift', 'filesystem'):
58+
if STORAGE_ENV not in ('swift', 'fslink', 'filesystem'):
5959
raise ImproperlyConfigured(f"Unsupported value '{STORAGE_ENV}' for STORAGE_ENV")
6060

6161
if STORAGE_ENV == 'swift':
@@ -72,7 +72,7 @@ def get_secret(setting, secret_type=env):
7272
SWIFT_CONTAINER_NAME=SWIFT_CONTAINER_NAME,
7373
SWIFT_CONNECTION_PARAMS=SWIFT_CONNECTION_PARAMS
7474
)
75-
elif STORAGE_ENV == 'filesystem':
75+
elif STORAGE_ENV in ('fslink', 'filesystem'):
7676
STORAGES['default'] = {'BACKEND': 'django.core.files.storage.FileSystemStorage'}
7777
MEDIA_ROOT = get_secret('MEDIA_ROOT')
7878
verify_storage = lambda: verify_storage_connection(DEFAULT_FILE_STORAGE=STORAGES['default']['BACKEND'],

chris_backend/core/api.py

Lines changed: 26 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,6 @@
5656
path('v1/comments/<int:pk>/',
5757
feed_views.CommentDetail.as_view(), name='comment-detail'),
5858

59-
path('v1/<int:pk>/files/',
60-
feed_views.FeedFileList.as_view(), name='feedfile-list'),
61-
6259
path('v1/<int:pk>/plugininstances/',
6360
feed_views.FeedPluginInstanceList.as_view(), name='feed-plugininstance-list'),
6461

@@ -235,26 +232,6 @@
235232
plugininstance_views.PluginInstanceDescendantList.as_view(),
236233
name='plugininstance-descendant-list'),
237234

238-
path('v1/plugins/instances/<int:pk>/files/',
239-
plugininstance_views.PluginInstanceFileList.as_view(),
240-
name='plugininstancefile-list'),
241-
242-
path('v1/files/',
243-
plugininstance_views.AllPluginInstanceFileList.as_view(),
244-
name='allplugininstancefile-list'),
245-
246-
path('v1/files/search/',
247-
plugininstance_views.AllPluginInstanceFileListQuerySearch.as_view(),
248-
name='allplugininstancefile-list-query-search'),
249-
250-
path('v1/files/<int:pk>/',
251-
plugininstance_views.PluginInstanceFileDetail.as_view(),
252-
name='plugininstancefile-detail'),
253-
254-
re_path(r'^v1/files/(?P<pk>[0-9]+)/.*$',
255-
plugininstance_views.FileResource.as_view(),
256-
name='plugininstancefile-resource'),
257-
258235
path('v1/plugins/instances/<int:pk>/parameters/',
259236
plugininstance_views.PluginInstanceParameterList.as_view(),
260237
name='plugininstance-parameter-list'),
@@ -378,20 +355,36 @@
378355

379356

380357
path('v1/filebrowser/',
381-
filebrowser_views.FileBrowserPathList.as_view(),
382-
name='filebrowserpath-list'),
358+
filebrowser_views.FileBrowserFolderList.as_view(),
359+
name='chrisfolder-list'),
383360

384361
path('v1/filebrowser/search/',
385-
filebrowser_views.FileBrowserPathListQuerySearch.as_view(),
386-
name='filebrowserpath-list-query-search'),
362+
filebrowser_views.FileBrowserFolderListQuerySearch.as_view(),
363+
name='chrisfolder-list-query-search'),
364+
365+
path('v1/filebrowser/<int:pk>/',
366+
filebrowser_views.FileBrowserFolderDetail.as_view(),
367+
name='chrisfolder-detail'),
368+
369+
path('v1/filebrowser/<int:pk>/children/',
370+
filebrowser_views.FileBrowserFolderChildList.as_view(),
371+
name='chrisfolder-child-list'),
372+
373+
path('v1/filebrowser/<int:pk>/files/',
374+
filebrowser_views.FileBrowserFolderFileList.as_view(),
375+
name='chrisfolder-file-list'),
376+
377+
path('v1/filebrowser/<int:pk>/linkfiles/',
378+
filebrowser_views.FileBrowserFolderLinkFileList.as_view(),
379+
name='chrisfolder-linkfile-list'),
387380

388-
path('v1/filebrowser/<path:path>/',
389-
filebrowser_views.FileBrowserPath.as_view(),
390-
name='filebrowserpath'),
381+
path('v1/filebrowser/linkfiles/<int:pk>/',
382+
filebrowser_views.FileBrowserLinkFileDetail.as_view(),
383+
name='chrislinkfile-detail'),
391384

392-
path('v1/filebrowser-files/<path:path>/',
393-
filebrowser_views.FileBrowserPathFileList.as_view(),
394-
name='filebrowserpathfile-list'),
385+
re_path(r'^v1/filebrowser/linkfiles/(?P<pk>[0-9]+)/.*$',
386+
filebrowser_views.FileBrowserLinkFileResource.as_view(),
387+
name='chrislinkfile-resource')
395388

396389
])
397390

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Generated by Django 4.2.5 on 2023-12-19 04:41
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
('core', '0003_alter_chrisinstance_id'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='ChrisFolder',
18+
fields=[
19+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('creation_date', models.DateTimeField(auto_now_add=True)),
21+
('path', models.CharField(max_length=1024, unique=True)),
22+
('size', models.BigIntegerField(default=0)),
23+
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
24+
('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='core.chrisfolder')),
25+
],
26+
options={
27+
'ordering': ('-path',),
28+
},
29+
),
30+
migrations.CreateModel(
31+
name='ChrisLinkFile',
32+
fields=[
33+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
34+
('creation_date', models.DateTimeField(auto_now_add=True)),
35+
('path', models.CharField(max_length=1024)),
36+
('is_folder', models.BooleanField()),
37+
('fname', models.FileField(max_length=1024, unique=True, upload_to='')),
38+
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
39+
('parent_folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chris_link_files', to='core.chrisfolder')),
40+
],
41+
),
42+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 4.2.5 on 2024-01-12 23:26
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0004_chrisfolder_chrislinkfile'),
10+
]
11+
12+
operations = [
13+
migrations.RemoveField(
14+
model_name='chrisfolder',
15+
name='size',
16+
),
17+
]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 4.2.5 on 2024-01-31 04:18
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0005_remove_chrisfolder_size'),
10+
]
11+
12+
operations = [
13+
migrations.RemoveField(
14+
model_name='chrislinkfile',
15+
name='is_folder',
16+
),
17+
migrations.AlterField(
18+
model_name='chrislinkfile',
19+
name='path',
20+
field=models.CharField(db_index=True, max_length=1024),
21+
),
22+
]

chris_backend/core/models.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11

22
import uuid
3+
import io
4+
import os
35

46
from django.db import models
7+
from django.conf import settings
8+
from django.contrib.auth.models import User
9+
import django_filters
10+
from django_filters.rest_framework import FilterSet
11+
12+
from .storage import connect_storage
13+
#from django.core.files.base import ContentFile
514

615

716
class ChrisInstance(models.Model):
@@ -38,3 +47,88 @@ def load(cls):
3847
obj = cls()
3948
obj.save()
4049
return obj
50+
51+
52+
class ChrisFolder(models.Model):
53+
creation_date = models.DateTimeField(auto_now_add=True)
54+
path = models.CharField(max_length=1024, unique=True) # folder's path
55+
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True,
56+
related_name='children')
57+
owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)
58+
59+
class Meta:
60+
ordering = ('-path',)
61+
62+
def __str__(self):
63+
return self.path
64+
65+
def save(self, *args, **kwargs):
66+
"""
67+
Overriden to recursively create parent folders when first saving the folder
68+
to the DB.
69+
"""
70+
if self.path:
71+
parent_path = os.path.dirname(self.path)
72+
try:
73+
parent = ChrisFolder.objects.get(path=parent_path)
74+
except ChrisFolder.DoesNotExist:
75+
parent = ChrisFolder(path=parent_path, owner=self.owner)
76+
parent.save() # recursive call
77+
self.parent = parent
78+
79+
if self.path in ('', 'home') or self.path.startswith(('PIPELINES', 'SERVICES')):
80+
self.owner = User.objects.get(username='chris')
81+
super(ChrisFolder, self).save(*args, **kwargs)
82+
83+
def get_descendants(self):
84+
"""
85+
Custom method to return all the folders that are a descendant of this
86+
folder.
87+
"""
88+
descendants = []
89+
queue = [self]
90+
while len(queue) > 0:
91+
visited = queue.pop()
92+
queue.extend(list(visited.children.all()))
93+
descendants.append(visited)
94+
return descendants
95+
96+
97+
class ChrisFolderFilter(FilterSet):
98+
path = django_filters.CharFilter(field_name='path')
99+
100+
class Meta:
101+
model = ChrisFolder
102+
fields = ['id', 'path']
103+
104+
105+
class ChrisLinkFile(models.Model):
106+
creation_date = models.DateTimeField(auto_now_add=True)
107+
path = models.CharField(max_length=1024, db_index=True) # pointed path
108+
fname = models.FileField(max_length=1024, unique=True)
109+
parent_folder = models.ForeignKey(ChrisFolder, on_delete=models.CASCADE,
110+
related_name='chris_link_files')
111+
owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)
112+
113+
def __str__(self):
114+
return self.fname.name
115+
116+
def save(self, *args, **kwargs):
117+
"""
118+
Overriden to create and save the associated link file when the link is
119+
saved.
120+
"""
121+
path = self.path # pointed path
122+
name = kwargs.pop('name') # must provide a name for the link
123+
link_file_path = os.path.join(self.parent_folder.path, f'{name}.chrislink')
124+
link_file_contents = f'{path}'
125+
126+
storage_manager = connect_storage(settings)
127+
128+
with io.StringIO(link_file_contents) as f:
129+
if storage_manager.obj_exists(link_file_path):
130+
storage_manager.delete_obj(link_file_path)
131+
storage_manager.upload_obj(link_file_path, f.read(),
132+
content_type='text/plain')
133+
self.fname.name = link_file_path
134+
super(ChrisLinkFile, self).save(*args, **kwargs)

chris_backend/core/serializers.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
from rest_framework import serializers
33

4-
from .models import ChrisInstance
4+
from .models import ChrisInstance, ChrisFolder
55

66

77
class ChrisInstanceSerializer(serializers.HyperlinkedModelSerializer):
@@ -10,3 +10,44 @@ class Meta:
1010
model = ChrisInstance
1111
fields = ('url', 'id', 'creation_date', 'name', 'uuid', 'job_id_prefix',
1212
'description')
13+
14+
15+
class ChrisFolderSerializer(serializers.HyperlinkedModelSerializer):
16+
parent = serializers.HyperlinkedRelatedField(view_name='chrisfolder-detail',
17+
read_only=True)
18+
children = serializers.HyperlinkedIdentityField(view_name='chrisfolder-child-list')
19+
user_files = serializers.HyperlinkedIdentityField(
20+
view_name='chrisfolder-userfile-list')
21+
pacs_files = serializers.HyperlinkedIdentityField(
22+
view_name='chrisfolder-pacsfile-list')
23+
service_files = serializers.HyperlinkedIdentityField(
24+
view_name='chrisfolder-servicefile-list')
25+
pipeline_source_files = serializers.HyperlinkedIdentityField(
26+
view_name='chrisfolder-pipelinesourcefile-list')
27+
link_files = serializers.HyperlinkedIdentityField(
28+
view_name='chrisfolder-linkfile-list')
29+
owner = serializers.HyperlinkedRelatedField(view_name='user-detail', read_only=True)
30+
31+
class Meta:
32+
model = ChrisFolder
33+
fields = ('url', 'id', 'creation_date', 'path', 'parent', 'children',
34+
'user_files', 'pacs_files', 'service_files', 'link_files',
35+
'pipeline_source_files', 'owner')
36+
37+
def validate_path(self, path):
38+
"""
39+
Overriden to check whether the provided path is under home/<username>/ but not
40+
under home/<username>/feeds/.
41+
"""
42+
# remove leading and trailing slashes
43+
path = path.strip(' ').strip('/')
44+
user = self.context['request'].user
45+
prefix = f'home/{user.username}/'
46+
if path.startswith(prefix + 'feeds/'):
47+
error_msg = f"Invalid file path. Creating folders with a path under the " \
48+
f"feed's directory '{prefix + 'feeds/'}' is not allowed."
49+
raise serializers.ValidationError([error_msg])
50+
if not path.startswith(prefix):
51+
error_msg = f"Invalid file path. Path must start with '{prefix}'."
52+
raise serializers.ValidationError([error_msg])
53+
return path

chris_backend/core/storage/plain_fs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ def create_container(self) -> None:
2222
self.__base.mkdir(exist_ok=True, parents=True)
2323

2424
def ls(self, path_prefix: str) -> List[str]:
25+
if self.obj_exists(path_prefix):
26+
return [path_prefix]
2527
all_paths = (self.__base / path_prefix).rglob('*')
2628
return [str(p.relative_to(self.__base)) for p in all_paths if p.is_file()]
2729

chris_backend/core/views.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11

2-
from rest_framework import generics, permissions
2+
import logging
33

4+
from rest_framework import generics, permissions
45
from .models import ChrisInstance
56
from .serializers import ChrisInstanceSerializer
67

78

9+
logger = logging.getLogger(__name__)
10+
11+
812
class ChrisInstanceDetail(generics.RetrieveAPIView):
913
"""
1014
A compute resource view.

0 commit comments

Comments
 (0)