Skip to content

Commit 3ee492d

Browse files
authored
Merge pull request #378 from jbernal0019/master
Implement the workflows Django app
2 parents 2d63355 + 9f78302 commit 3ee492d

File tree

20 files changed

+858
-10
lines changed

20 files changed

+858
-10
lines changed

.github/workflows/dev.yml

100644100755
File mode changed.

chris_backend/config/settings/common.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
'pacsfiles',
4242
'servicefiles',
4343
'filebrowser',
44-
'users'
44+
'users',
45+
'workflows'
4546
]
4647

4748
# Pagination

chris_backend/config/settings/local.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272

7373
for app in ['collectionjson', 'core', 'feeds', 'plugins', 'plugininstances', 'pipelines',
7474
'pipelineinstances', 'uploadedfiles', 'pacsfiles', 'servicefiles', 'users',
75-
'filebrowser']:
75+
'filebrowser', 'workflows']:
7676
LOGGING['loggers'][app] = {
7777
'level': 'DEBUG',
7878
'handlers': ['console_verbose', 'file'],

chris_backend/core/api.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from plugininstances import views as plugininstance_views
1010
from pipelines import views as pipeline_views
1111
from pipelineinstances import views as pipelineinstance_views
12+
from workflows import views as workflow_views
1213
from uploadedfiles import views as uploadedfile_views
1314
from pacsfiles import views as pacsfile_views
1415
from servicefiles import views as servicefile_views
@@ -276,6 +277,23 @@
276277
name='pipelineinstance-plugininstance-list'),
277278

278279

280+
path('v1/pipelines/<int:pk>/workflows/',
281+
workflow_views.WorkflowList.as_view(),
282+
name='workflow-list'),
283+
284+
path('v1/pipelines/workflows/',
285+
workflow_views.AllWorkflowList.as_view(),
286+
name='allworkflow-list'),
287+
288+
path('v1/pipelines/workflows/search/',
289+
workflow_views.AllWorkflowListQuerySearch.as_view(),
290+
name='allworkflow-list-query-search'),
291+
292+
path('v1/pipelines/workflows/<int:pk>/',
293+
workflow_views.WorkflowDetail.as_view(),
294+
name='workflow-detail'),
295+
296+
279297
path('v1/uploadedfiles/',
280298
uploadedfile_views.UploadedFileList.as_view(),
281299
name='uploadedfile-list'),

chris_backend/feeds/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ def list(self, request, *args, **kwargs):
303303
'pipelines': reverse('pipeline-list', request=request),
304304
'pipeline_instances': reverse('allpipelineinstance-list',
305305
request=request),
306+
'workflows': reverse('allworkflow-list', request=request),
306307
'tags': reverse('tag-list', request=request),
307308
'uploadedfiles': reverse('uploadedfile-list', request=request),
308309
'pacsfiles': reverse('pacsfile-list', request=request),

chris_backend/pipelineinstances/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def validate_previous_plugin_inst(self, previous_plugin_inst_id):
4848
except (ValueError, ObjectDoesNotExist):
4949
raise serializers.ValidationError(
5050
{'previous_plugin_inst_id':
51-
["Couldn't find any 'previous' plugin instance with id %s."]})
51+
[f"Couldn't find any 'previous' plugin instance with id {pk}."]})
5252
# check that the user can run plugins within this feed
5353
user = self.context['request'].user
5454
if user not in previous_plugin_inst.feed.owner.all():

chris_backend/pipelines/serializers.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,14 @@ class PipelineSerializer(serializers.HyperlinkedModelSerializer):
2727
default_parameters = serializers.HyperlinkedIdentityField(
2828
view_name='pipeline-defaultparameter-list')
2929
instances = serializers.HyperlinkedIdentityField(view_name='pipelineinstance-list')
30+
workflows = serializers.HyperlinkedIdentityField(view_name='workflow-list')
3031

3132
class Meta:
3233
model = Pipeline
3334
fields = ('url', 'id', 'name', 'locked', 'authors', 'category', 'description',
3435
'plugin_tree', 'plugin_inst_id', 'owner_username', 'creation_date',
3536
'modification_date', 'plugins', 'plugin_pipings', 'default_parameters',
36-
'instances')
37+
'instances', 'workflows')
3738

3839
def create(self, validated_data):
3940
"""
@@ -118,10 +119,10 @@ def validate_plugin_inst_id(self, plugin_inst_id):
118119
raise serializers.ValidationError(
119120
["Couldn't find any plugin instance with id %s." % plugin_inst_id])
120121
plg = plg_inst.plugin
121-
if plg.meta.type == 'fs':
122+
if plg.meta.type != 'ds':
122123
raise serializers.ValidationError(
123-
["Plugin instance of %s which is of type 'fs' and therefore can not "
124-
"be used as the root of a new pipeline." % plg.meta.name])
124+
[f"Plugin instance of %s which is of type {plg.meta.type} and therefore "
125+
f"can not be used as the root of a new pipeline." % plg.meta.name])
125126
return plg_inst
126127

127128
def validate_plugin_tree(self, plugin_tree):
@@ -313,19 +314,21 @@ def _add_plugin_tree_to_pipeline(pipeline, tree_dict):
313314

314315
class PluginPipingSerializer(serializers.HyperlinkedModelSerializer):
315316
plugin_id = serializers.ReadOnlyField(source='plugin.id')
317+
plugin_name = serializers.ReadOnlyField(source='plugin.meta.name')
318+
plugin_version = serializers.ReadOnlyField(source='plugin.version')
316319
pipeline_id = serializers.ReadOnlyField(source='pipeline.id')
317320
previous_id = serializers.ReadOnlyField(source='previous.id')
318321
previous = serializers.HyperlinkedRelatedField(view_name='pluginpiping-detail',
319-
read_only=True)
322+
read_only=True)
320323
plugin = serializers.HyperlinkedRelatedField(view_name='plugin-detail',
321324
read_only=True)
322325
pipeline = serializers.HyperlinkedRelatedField(view_name='pipeline-detail',
323326
read_only=True)
324327

325328
class Meta:
326329
model = PluginPiping
327-
fields = ('url', 'id', 'plugin_id', 'pipeline_id', 'previous_id', 'previous',
328-
'plugin', 'pipeline')
330+
fields = ('url', 'id', 'previous_id', 'plugin_id', 'plugin_name',
331+
'plugin_version', 'pipeline_id', 'previous', 'plugin', 'pipeline')
329332

330333

331334
class DefaultPipingStrParameterSerializer(serializers.HyperlinkedModelSerializer):

chris_backend/plugininstances/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,11 @@ class Meta:
353353

354354
def __str__(self):
355355
return self.value
356+
357+
358+
PARAMETER_MODELS = {'string': StrParameter,
359+
'integer': IntParameter,
360+
'float': FloatParameter,
361+
'boolean': BoolParameter,
362+
'path': PathParameter,
363+
'unextpath': UnextpathParameter}

chris_backend/workflows/__init__.py

Whitespace-only changes.

chris_backend/workflows/admin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from django.contrib import admin
2+
3+
# Register your models here.

chris_backend/workflows/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.apps import AppConfig
2+
3+
4+
class WorkflowsConfig(AppConfig):
5+
name = 'workflows'
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Generated by Django 2.2.24 on 2022-02-27 00:49
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+
initial = True
11+
12+
dependencies = [
13+
('pipelines', '0005_auto_20200213_0040'),
14+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='Workflow',
20+
fields=[
21+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22+
('creation_date', models.DateTimeField(auto_now_add=True)),
23+
('created_plugin_inst_ids', models.CharField(max_length=600)),
24+
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
25+
('pipeline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workflows', to='pipelines.Pipeline')),
26+
],
27+
options={
28+
'ordering': ('-creation_date',),
29+
},
30+
),
31+
]

chris_backend/workflows/migrations/__init__.py

Whitespace-only changes.

chris_backend/workflows/models.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
2+
from django.db import models
3+
import django_filters
4+
from django_filters.rest_framework import FilterSet
5+
6+
from pipelines.models import Pipeline
7+
8+
9+
class Workflow(models.Model):
10+
creation_date = models.DateTimeField(auto_now_add=True)
11+
created_plugin_inst_ids = models.CharField(max_length=600)
12+
pipeline = models.ForeignKey(Pipeline, on_delete=models.CASCADE,
13+
related_name='workflows')
14+
owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)
15+
16+
class Meta:
17+
ordering = ('-creation_date',)
18+
19+
def __str__(self):
20+
return self.created_plugin_inst_ids
21+
22+
23+
class WorkflowFilter(FilterSet):
24+
pipeline_name = django_filters.CharFilter(field_name='pipeline__name',
25+
lookup_expr='icontains')
26+
owner_username = django_filters.CharFilter(field_name='owner__username',
27+
lookup_expr='exact')
28+
29+
class Meta:
30+
model = Workflow
31+
fields = ['id', 'pipeline_name', 'owner_username']
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from rest_framework import permissions
2+
3+
4+
class IsOwnerOrChrisOrReadOnly(permissions.BasePermission):
5+
"""
6+
Custom permission to only allow owners of an object or superuser
7+
'chris' to modify/edit it. Read only is allowed to other users.
8+
"""
9+
10+
def has_object_permission(self, request, view, obj):
11+
# Read permissions are allowed to any request,
12+
# so we'll always allow GET, HEAD or OPTIONS requests.
13+
if request.method in permissions.SAFE_METHODS:
14+
return True
15+
16+
# Write permissions are only allowed to the owner and superuser 'chris'.
17+
return (request.user == obj.owner) or (request.user.username == 'chris')
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
2+
import json
3+
4+
from django.core.exceptions import ObjectDoesNotExist
5+
from rest_framework import serializers
6+
7+
from plugininstances.models import PluginInstance
8+
from plugininstances.serializers import PluginInstanceSerializer
9+
from pipelines.serializers import DEFAULT_PIPING_PARAMETER_SERIALIZERS
10+
11+
from .models import Workflow
12+
13+
14+
class WorkflowSerializer(serializers.HyperlinkedModelSerializer):
15+
created_plugin_inst_ids = serializers.ReadOnlyField()
16+
pipeline_id = serializers.ReadOnlyField(source='pipeline.id')
17+
pipeline_name = serializers.ReadOnlyField(source='pipeline.name')
18+
previous_plugin_inst_id = serializers.IntegerField(min_value=1, write_only=True)
19+
nodes_info = serializers.JSONField(write_only=True)
20+
owner_username = serializers.ReadOnlyField(source='owner.username')
21+
pipeline = serializers.HyperlinkedRelatedField(view_name='pipeline-detail',
22+
read_only=True)
23+
24+
class Meta:
25+
model = Workflow
26+
fields = ('url', 'id', 'creation_date', 'created_plugin_inst_ids', 'pipeline_id',
27+
'pipeline_name', 'owner_username', 'previous_plugin_inst_id',
28+
'nodes_info', 'pipeline')
29+
30+
def create(self, validated_data):
31+
"""
32+
Overriden to delete 'previous_plugin_inst_id' and 'nodes_info' from serializer
33+
data as they are not model fields.
34+
"""
35+
del validated_data['previous_plugin_inst_id']
36+
del validated_data['nodes_info']
37+
return super(WorkflowSerializer, self).create(validated_data)
38+
39+
def validate_previous_plugin_inst_id(self, previous_plugin_inst_id):
40+
"""
41+
Overriden to check that an integer id is provided for previous plugin instance.
42+
Then check that the id exists in the DB and that the user can run plugins
43+
within the corresponding feed.
44+
"""
45+
if not previous_plugin_inst_id:
46+
raise serializers.ValidationError(['This field is required.'])
47+
try:
48+
pk = int(previous_plugin_inst_id)
49+
previous_plugin_inst = PluginInstance.objects.get(pk=pk)
50+
except (ValueError, ObjectDoesNotExist):
51+
raise serializers.ValidationError(
52+
[f"Couldn't find any 'previous' plugin instance with id "
53+
f"{previous_plugin_inst_id}."])
54+
# check that the user can run plugins within this feed
55+
user = self.context['request'].user
56+
if user not in previous_plugin_inst.feed.owner.all():
57+
raise serializers.ValidationError([f'User is not an owner of feed for '
58+
f'previous instance with id {pk}.'])
59+
return previous_plugin_inst
60+
61+
def validate_nodes_info(self, nodes_info):
62+
"""
63+
Overriden to validate the runtime data for the workflow. It should be a
64+
JSON string encoding a list of dictionaries. Each dictionary is a workflow node
65+
containing a plugin piping_id, compute_resource_name, title and a list of
66+
dictionaries called plugin_parameter_defaults. Each dictionary in this list has
67+
name and default keys.
68+
"""
69+
try:
70+
node_list = list(json.loads(nodes_info))
71+
except json.decoder.JSONDecodeError:
72+
# overriden validation methods automatically add the field name to the msg
73+
raise serializers.ValidationError([f'Invalid JSON string {nodes_info}.'])
74+
except Exception:
75+
raise serializers.ValidationError([f'Invalid list in {nodes_info}'])
76+
77+
pipeline = self.context['view'].get_object()
78+
pipings = list(pipeline.plugin_pipings.all())
79+
if len(node_list) != len(pipings):
80+
raise serializers.ValidationError(
81+
[f'Invalid length for list in {nodes_info}'])
82+
83+
for piping in pipings:
84+
d_l = [d for d in node_list if d.get('piping_id') == piping.id]
85+
try:
86+
d = d_l[0]
87+
except IndexError:
88+
raise serializers.ValidationError([f'Missing data for plugin pipping '
89+
f'with id {piping.id}'])
90+
cr_name = d.get('compute_resource_name')
91+
if not cr_name:
92+
raise serializers.ValidationError([f'Missing compute_resource_name key in'
93+
f' {d}'])
94+
if piping.plugin.compute_resources.filter(name=cr_name).count() == 0:
95+
msg = [f'Plugin for pipping with id {piping.id} has not been registered '
96+
f'with a compute resource named {cr_name}']
97+
raise serializers.ValidationError(msg)
98+
99+
title = d.get('title', '')
100+
plg_inst_serializer = PluginInstanceSerializer(data={'title': title})
101+
try:
102+
plg_inst_serializer.is_valid(raise_exception=True)
103+
except Exception:
104+
msg = [f'Invalid title {title} for pipping with id {piping.id}']
105+
raise serializers.ValidationError(msg)
106+
107+
piping_param_defaults = d.get('plugin_parameter_defaults', [])
108+
self.validate_piping_params(piping.id, piping.string_param.all(),
109+
piping_param_defaults)
110+
self.validate_piping_params(piping.id, piping.integer_param.all(),
111+
piping_param_defaults)
112+
self.validate_piping_params(piping.id, piping.float_param.all(),
113+
piping_param_defaults)
114+
self.validate_piping_params(piping.id, piping.boolean_param.all(),
115+
piping_param_defaults)
116+
return node_list
117+
118+
@staticmethod
119+
def validate_piping_params(piping_id, piping_default_params, piping_param_defaults):
120+
"""
121+
Helper method to validate that if a default value doesn't exist in the
122+
corresponding pipeline for a piping parameter then a default is provided for it
123+
when creating a new runtime workflow.
124+
"""
125+
for default_param in piping_default_params:
126+
l = [d for d in piping_param_defaults if
127+
d.get('name') == default_param.plugin_param.name]
128+
if default_param.value is None and (not l or l[0].get('default') is None):
129+
raise serializers.ValidationError(
130+
[f"Can not run workflow. Parameter "
131+
f"'{default_param.plugin_param.name}' for piping with id "
132+
f"{piping_id} does not have a default value in the pipeline"])
133+
if l and l[0].get('default'):
134+
param_default = l[0].get('default')
135+
param_type = default_param.plugin_param.type
136+
default_serializer_cls = DEFAULT_PIPING_PARAMETER_SERIALIZERS[param_type]
137+
default_serializer = default_serializer_cls(data={'value': param_default})
138+
try:
139+
default_serializer.is_valid(raise_exception=True)
140+
except Exception:
141+
msg = [f'Invalid parameter default value {param_default} for '
142+
f"parameter '{default_param.plugin_param.name}' and pipping "
143+
f'with id {piping_id}']
144+
raise serializers.ValidationError(msg)

chris_backend/workflows/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)