diff --git a/front-end/src/API.js b/front-end/src/API.js index 93050e4..8df7b4b 100644 --- a/front-end/src/API.js +++ b/front-end/src/API.js @@ -98,10 +98,16 @@ export async function getNodes() { /** * Start a new workflow on the server + * @param {DiagramModel} model - Diagram model * @returns {Promise} - server response */ -export async function initWorkflow() { - return fetchWrapper("/workflow/new"); +export async function initWorkflow(model) { + const options = { + method: "POST", + body: JSON.stringify(model.options.id) + }; + + return fetchWrapper("/workflow/new", options); } diff --git a/front-end/src/components/Workspace.js b/front-end/src/components/Workspace.js index 7f9dca1..57c0f32 100644 --- a/front-end/src/components/Workspace.js +++ b/front-end/src/components/Workspace.js @@ -32,7 +32,7 @@ class Workspace extends React.Component { API.getNodes() .then(nodes => this.setState({nodes: nodes})) .catch(err => console.log(err)); - API.initWorkflow().catch(err => console.log(err)); + API.initWorkflow(this.model).catch(err => console.log(err)); } /** @@ -51,7 +51,7 @@ class Workspace extends React.Component { clear() { if (window.confirm("Clear diagram? You will lose all work.")) { this.model.getNodes().forEach(n => n.remove()); - API.initWorkflow().catch(err => console.log(err)); + API.initWorkflow(this.model).catch(err => console.log(err)); this.engine.repaintCanvas(); } } @@ -128,7 +128,7 @@ function FileUpload(props) { const form = new FormData(); form.append("file", file); API.uploadWorkflow(form).then(json => { - props.handleData(json.react); + props.handleData(json); }).catch(err => { console.log(err); }); diff --git a/pyworkflow/pyworkflow/workflow.py b/pyworkflow/pyworkflow/workflow.py index effe898..c540b4e 100644 --- a/pyworkflow/pyworkflow/workflow.py +++ b/pyworkflow/pyworkflow/workflow.py @@ -5,36 +5,27 @@ from .node import Node from .node_factory import node_factory -from django.conf import settings - class Workflow: """ Workflow object Attributes: + name: Name of the workflow + root_dir: Used for reading/writing files to/from disk graph: A NetworkX Directed Graph - file_path: Location of a workflow file + flow_vars: Global flow variables associated with workflow """ - def __init__(self, graph=nx.DiGraph(), file_path=None, name='a-name', flow_vars=nx.Graph(), root_dir=settings.MEDIA_ROOT): - #TODO: need to discuss a way to generating the workflow name. For now passing a default name. - self._graph = graph - self._file_path = file_path + def __init__(self, name="Untitled", root_dir=None, graph=nx.DiGraph(), flow_vars=nx.Graph()): self._name = name + self._root_dir = WorkflowUtils.set_root_dir(root_dir) + self._graph = graph self._flow_vars = flow_vars - if not os.path.exists(root_dir): - os.makedirs(root_dir) - self._root_dir = root_dir - @property def graph(self): return self._graph - @graph.setter - def graph(self, graph): - self._graph = graph - def path(self, file_name): return os.path.join(self.root_dir, file_name) @@ -42,17 +33,6 @@ def path(self, file_name): def root_dir(self): return self._root_dir - @property - def file_path(self): - return self._file_path - - @file_path.setter - def file_path(self, file_path: str): - if file_path is None or file_path[-5:] == '.json': - self._file_path = file_path - else: - raise WorkflowException('set_file_path', 'File ' + file_path + ' is not JSON.') - @property def flow_vars(self): return self._flow_vars @@ -116,6 +96,10 @@ def name(self): def name(self, name: str): self._name = name + @property + def filename(self): + return self.name + '.json' + def add_edge(self, node_from: Node, node_to: Node): """ Add a Node object to the graph. @@ -247,7 +231,7 @@ def execution_order(self): def upload_file(self, uploaded_file, node_id): try: file_name = f"{node_id}-{uploaded_file.name}" - to_open = os.path.join(self.root_dir, file_name) + to_open = self.path(file_name) # TODO: Change to a stream/other method for large files? with open(to_open, 'wb') as f: @@ -265,14 +249,13 @@ def download_file(self, node_id): try: # TODO: Change to generic "file" option to allow for more than WriteCsv - to_open = os.path.join(self.root_dir, node.options["path_or_buf"]) + to_open = self.path(node.options['path_or_buf']) return open(to_open) except KeyError: raise WorkflowException('download_file', '%s does not have an associated file' % node_id) except OSError as e: raise WorkflowException('download_file', str(e)) - @staticmethod def store_node_data(workflow, node_id, data): """Store Node data @@ -288,9 +271,10 @@ def store_node_data(workflow, node_id, data): """ file_name = Workflow.generate_file_name(workflow, node_id) + file_path = workflow.path(file_name) try: - with open(file_name, 'w') as f: + with open(file_path, 'w') as f: f.write(data) return file_name except Exception as e: @@ -326,79 +310,57 @@ def retrieve_node_data(node_to_retrieve): raise WorkflowException('retrieve node data', str(e)) @staticmethod - def read_graph_json(file_like): + def read_graph_json(json_data): """Deserialize JSON NetworkX graph Args: - file_like: file-like object from which to read JSON-serialized graph + json_data: JSON data from which to read JSON-serialized graph Returns: NetworkX DiGraph object Raises: - OSError: on file error NetworkXError: on issue with loading JSON graph data """ - json_data = json.load(file_like) return nx.readwrite.json_graph.node_link_graph(json_data) @staticmethod def generate_file_name(workflow, node_id): """Generates a file name for saving intermediate execution data. - Current format is workflow_name - node_id + Current format is 'workflow_name - node_id' Args: workflow: the workflow node_id: the id of the workflow """ - #TODO: need to add validation - file_name = workflow.name if workflow.name else 'a-name' - return os.path.join(workflow.root_dir, file_name + '-' + str(node_id)) + return f"{workflow.name}-{node_id}" @classmethod - def from_session(cls, data): - """Create instance from graph (JSON) data and filename - - Typically takes Django session as argument, which contains - `graph` and `file_path` keys. + def from_json(cls, json_data): + """Load Workflow from JSON data. Args: - data: dict-like with keys `file_path` and `graph` - """ - file_path = data.get('file_path') - graph_data = data.get('graph') - name = data.get('name') - flow_vars_data = data.get('flow_vars') - root_dir = data.get('root_dir') - - if graph_data is None: - graph = None - else: - graph = nx.readwrite.json_graph.node_link_graph(graph_data) - - if flow_vars_data is None: - flow_vars = None - else: - flow_vars = nx.readwrite.json_graph.node_link_graph(flow_vars_data) - - return cls(graph, file_path, name, flow_vars, root_dir) - - @classmethod - def from_file(cls, file_like): - """ - - """ - graph = cls.read_graph_json(file_like) - return cls(graph) + json_data: JSON-like data from session, or uploaded file - @classmethod - def from_request(cls, json_data): - """ + Returns: + New Workflow object + Raises: + WorkflowException: on missing data (KeyError) or on + malformed NetworkX graph data (NetworkXError) """ - graph = nx.readwrite.json_graph.node_link_graph(json_data) - return cls(graph) + try: + name = json_data['name'] + root_dir = json_data['root_dir'] + graph = Workflow.read_graph_json(json_data['graph']) + flow_vars = Workflow.read_graph_json(json_data['flow_vars']) + + return cls(name=name, root_dir=root_dir, graph=graph, flow_vars=flow_vars) + except KeyError as e: + raise WorkflowException('from_json', str(e)) + except nx.NetworkXError as e: + raise WorkflowException('from_json', str(e)) @staticmethod def to_graph_json(graph): @@ -407,13 +369,27 @@ def to_graph_json(graph): def to_session_dict(self): """Store Workflow information in the Django session. """ - out = dict() - out['graph'] = Workflow.to_graph_json(self.graph) - out['file_path'] = self.file_path - out['name'] = self.name - out['flow_vars'] = Workflow.to_graph_json(self.flow_vars) - out['root_dir'] = self.root_dir - return out + try: + out = dict() + out['name'] = self.name + out['root_dir'] = self.root_dir + out['graph'] = Workflow.to_graph_json(self.graph) + out['flow_vars'] = Workflow.to_graph_json(self.flow_vars) + return out + except nx.NetworkXError as e: + raise WorkflowException('to_session_dict', str(e)) + + +class WorkflowUtils: + @staticmethod + def set_root_dir(root_dir): + if root_dir is None: + root_dir = os.getcwd() + + if not os.path.exists(root_dir): + os.makedirs(root_dir) + + return root_dir class WorkflowException(Exception): diff --git a/vp/workflow/middleware.py b/vp/workflow/middleware.py index 5425732..f6b0893 100644 --- a/vp/workflow/middleware.py +++ b/vp/workflow/middleware.py @@ -1,4 +1,4 @@ -from pyworkflow import Workflow +from pyworkflow import Workflow, WorkflowException from django.http import JsonResponse @@ -25,13 +25,16 @@ def __call__(self, request): pass else: # All other cases, load workflow from session - request.pyworkflow = Workflow.from_session(request.session) - - # Check if a graph is present - if request.pyworkflow.graph is None: - return JsonResponse({ - 'message': 'A workflow has not been created yet.' - }, status=404) + try: + request.pyworkflow = Workflow.from_json(request.session) + + # Check if a graph is present + if request.pyworkflow.graph is None: + return JsonResponse({ + 'message': 'A workflow has not been created yet.' + }, status=404) + except WorkflowException as e: + return JsonResponse({e.action: e.reason}, status=500) response = self.get_response(request) diff --git a/vp/workflow/urls.py b/vp/workflow/urls.py index 3aaa034..26f1ad7 100644 --- a/vp/workflow/urls.py +++ b/vp/workflow/urls.py @@ -4,6 +4,7 @@ urlpatterns = [ path('new', views.new_workflow, name='new workflow'), path('open', views.open_workflow, name='open workflow'), + path('edit', views.edit_workflow, name='edit workflow'), path('save', views.save_workflow, name='save'), path('execute', views.execute_workflow, name='execute workflow'), path('execute//successors', views.get_successors, name='get node successors'), diff --git a/vp/workflow/views.py b/vp/workflow/views.py index c54f370..a76c28c 100644 --- a/vp/workflow/views.py +++ b/vp/workflow/views.py @@ -8,13 +8,13 @@ from drf_yasg.utils import swagger_auto_schema -@swagger_auto_schema(method='get', +@swagger_auto_schema(method='post', operation_summary='Create a new workflow.', operation_description='Creates a new workflow with empty DiGraph.', responses={ 200: 'Created new DiGraph' }) -@api_view(['GET']) +@api_view(['POST']) def new_workflow(request): """Create a new workflow. @@ -23,8 +23,13 @@ def new_workflow(request): Return: 200 - Created new DiGraph """ + try: + workflow_id = json.loads(request.body) + except json.JSONDecodeError as e: + return JsonResponse({'No React model ID provided': str(e)}, status=500) + # Create new Workflow - request.pyworkflow = Workflow(root_dir=settings.MEDIA_ROOT) + request.pyworkflow = Workflow(name=workflow_id, root_dir=settings.MEDIA_ROOT) request.session.update(request.pyworkflow.to_session_dict()) return JsonResponse(Workflow.to_graph_json(request.pyworkflow.graph)) @@ -49,12 +54,17 @@ def open_workflow(request): request: Django request Object, should follow the pattern: { react: {react-diagrams JSON}, - networkx: {networkx graph as JSON}, + pyworkflow: { + name: Workflow name, + root_dir: File storage, + graph: Computational graph, + flow_vars: Global flow variables, + }, } Raises: JSONDecodeError: invalid JSON data - KeyError: request missing either 'react' or 'networkx' data + KeyError: request missing either 'react' or 'pyworkflow' data WorkflowException: error loading JSON into NetworkX DiGraph Returns: @@ -69,9 +79,11 @@ def open_workflow(request): uploaded_file = request.FILES.get('file') combined_json = json.load(uploaded_file) - request.pyworkflow = Workflow.from_request(combined_json['networkx']) + request.pyworkflow = Workflow.from_json(combined_json['pyworkflow']) request.session.update(request.pyworkflow.to_session_dict()) - react = combined_json['react'] + + # Send back front-end workflow + return JsonResponse(combined_json['react']) except KeyError as e: return JsonResponse({'open_workflow': 'Missing data for ' + str(e)}, status=500) except json.JSONDecodeError as e: @@ -79,11 +91,23 @@ def open_workflow(request): except WorkflowException as e: return JsonResponse({e.action: e.reason}, status=404) - # Construct response - return JsonResponse({ - 'react': react, - 'networkx': Workflow.to_graph_json(request.pyworkflow.graph), - }) + +@swagger_auto_schema(method='post', + operation_summary='Edit workflow information', + operation_description='Edits workflow information.', + responses={ + 200: 'Workflow info updated', + 500: 'No valid JSON in request body' + }) +@api_view(['POST']) +def edit_workflow(request): + try: + json_data = json.loads(request.body) + request.pyworkflow.name = json_data['name'] + + return JsonResponse({'message': 'Workflow successfully updated.'}) + except Exception as e: + return JsonResponse({'message': str(e)}, status=404) @swagger_auto_schema(method='post', @@ -111,11 +135,14 @@ def save_workflow(request): # serialized graph try: combined_json = json.dumps({ + 'filename': request.pyworkflow.filename, 'react': json.loads(request.body), - 'networkx': Workflow.to_graph_json(request.pyworkflow.graph), - 'flow_vars': Workflow.to_graph_json(request.pyworkflow.flow_vars), - 'filename': request.pyworkflow.file_path, - 'root_dir': request.pyworkflow.root_dir, + 'pyworkflow': { + 'name': request.pyworkflow.name, + 'root_dir': request.pyworkflow.root_dir, + 'graph': Workflow.to_graph_json(request.pyworkflow.graph), + 'flow_vars': Workflow.to_graph_json(request.pyworkflow.flow_vars), + } }) return HttpResponse(combined_json, content_type='application/json')