From 190a5c370624e3b29cada0f20f0bb32158252c39 Mon Sep 17 00:00:00 2001 From: donkirkby Date: Wed, 20 Feb 2019 13:44:49 -0800 Subject: [PATCH] Split pipeline submit into Save and Save as..., as part of #751. --- kive/container/ajax.py | 49 +++++++++- kive/container/models.py | 3 +- .../static/container/container_content.ts | 22 ++++- kive/container/static/container/drydock.css | 7 +- .../static/container/io/PipelineApi.d.ts | 3 + .../static/container/io/pipeline_submit.ts | 23 ++++- .../static/container/pipeline_dialogs.ts | 1 + .../container/container_content.html | 16 +++- .../container/content_save_as_dialog.tpl.html | 20 ++++ .../container/content_view_dialog.tpl.html | 1 - kive/container/tests.py | 95 ++++++++++++++++++- 11 files changed, 225 insertions(+), 15 deletions(-) create mode 100644 kive/container/templates/container/content_save_as_dialog.tpl.html diff --git a/kive/container/ajax.py b/kive/container/ajax.py index 1fecae3fd..e00926402 100644 --- a/kive/container/ajax.py +++ b/kive/container/ajax.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from wsgiref.util import FileWrapper +from django.core.files.base import File from django.db.models import Q from django.db.models.aggregates import Count from django.http import HttpResponse @@ -12,6 +13,8 @@ from rest_framework import permissions from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied +from rest_framework.parsers import JSONParser, FormParser, MultiPartParser +from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet @@ -21,6 +24,7 @@ ContainerSerializer, ContainerAppSerializer, \ ContainerFamilyChoiceSerializer, ContainerRunSerializer, BatchSerializer, \ ContainerArgumentSerializer, ContainerDatasetSerializer, ContainerLogSerializer +from file_access_utils import use_field_file from kive.ajax import CleanCreateModelMixin, RemovableModelViewSet, \ SearchableModelMixin, IsDeveloperOrGrantedReadOnly, StandardPagination, \ IsGrantedReadCreate, GrantedModelMixin, IsGrantedReadOnly @@ -137,12 +141,43 @@ class ContainerChoiceViewSet(ReadOnlyModelViewSet, SearchableModelMixin): description__icontains=value)) +class ContainerRenderer(JSONRenderer): + """ Render the Raw data form for content_put to hold current content. """ + def render(self, data, accepted_media_type=None, renderer_context=None): + if renderer_context['view'].action == 'content_put': + data = dict(renderer_context['response'].data) + + # Remove ignored fields. + data.pop('files', None) + data.pop('id', None) + + # Add new fields that trigger a copy. + data['new_tag'] = None + data['new_description'] = None + rendered = super(ContainerRenderer, self).render(data, accepted_media_type, renderer_context) + return rendered + + +class ContainerJSONParser(JSONParser): + renderer_class = ContainerRenderer + + class ContainerViewSet(CleanCreateModelMixin, RemovableModelViewSet, SearchableModelMixin): """ A Singularity container. - Query parameters: + Extra actions: + + * Container Apps - a list of apps in this container + * Download - download the container file + * Container Removal Plan - standard removal plan, including child records + * Container Content - pipeline definition for archive containers. You can + also PUT to this endpoint to update the pipeline definition. If your + PUT data includes `new_tag`, then it will write the new pipeline to + a copy of the container. + + Container list query parameters: * is_granted - true For administrators, this limits the list to only include records that the user has been explicitly granted access to. For other @@ -169,6 +204,7 @@ class ContainerViewSet(CleanCreateModelMixin, serializer_class = ContainerSerializer permission_classes = (permissions.IsAuthenticated, IsDeveloperOrGrantedReadOnly) pagination_class = StandardPagination + parser_classes = [ContainerJSONParser, FormParser, MultiPartParser] filters = dict( family_id=lambda queryset, value: queryset.filter( family_id=value), @@ -220,9 +256,20 @@ def content_put(self, request, pk=None): container = self.get_object() content = request.data status_code = HttpResponseBadRequest.status_code + new_tag = content.get('new_tag') + new_description = content.get('new_description') if 'pipeline' not in content: response_data = dict(pipeline=['This field is required.']) + elif new_tag and Container.objects.filter(tag=new_tag).exists(): + response_data = dict(new_tag=['Tag already exists.']) else: + if new_tag: + container.pk = None # Saves a copy. + container.tag = new_tag + if new_description: + container.description = new_description + with use_field_file(container.file): + container.file.save(container.file.name, File(container.file)) container.write_content(content) container.save() response_data = container.get_content() diff --git a/kive/container/models.py b/kive/container/models.py index deae26ac3..142b1a2eb 100644 --- a/kive/container/models.py +++ b/kive/container/models.py @@ -399,7 +399,8 @@ def get_content(self, add_default=True): content = dict(files=sorted(entry.name for entry in archive.infolist() if not entry.name.startswith('kive/')), - pipeline=pipeline) + pipeline=pipeline, + id=self.pk) return content def create_new_pipeline_revision(self, tag=None, description=None): diff --git a/kive/container/static/container/container_content.ts b/kive/container/static/container/container_content.ts index cd2b12411..8a03fabce 100755 --- a/kive/container/static/container/container_content.ts +++ b/kive/container/static/container/container_content.ts @@ -44,6 +44,7 @@ CanvasListeners.initResizeListeners(canvasState); */ let text = $("#initial_data").text(); let loader = new Pipeline(canvasState); +let $containerPk = $('#id_container_pk'); let $memory = $("#id_memory"); let $threads = $("#id_threads"); let $error = $('#id_submit_error'); @@ -88,6 +89,8 @@ pipelineCheckCompleteness(); let $ctrl_nav = $("#id_ctrl_nav"); let $add_menu = $('#id_add_ctrl'); let $view_menu = $('#id_view_ctrl'); +let $save_as_ctrl = $('#id_save_as_ctrl'); +let $submit_as_ctrl = $('#id_submit_as_button'); /* anonymous */ new Dialog( $('#id_defaults_ctrl'), $ctrl_nav.find("li[data-rel='#id_defaults_ctrl']") ); /* anonymous */ new ViewDialog( $view_menu, $ctrl_nav.find("li[data-rel='#id_view_ctrl']") ); @@ -98,6 +101,7 @@ let method_dialog = new MethodDialog( $add_menu.find("li[data-rel='#id_method_ctrl']"), loader.container); let output_dialog = new OutputDialog( $('#id_output_ctrl'), $add_menu.find("li[data-rel='#id_output_ctrl']") ); +let save_as_dialog = new Dialog( $save_as_ctrl, $submit_as_ctrl ); $add_menu.click('li', function() { add_menu.hide(); }); @@ -132,13 +136,27 @@ $view_menu.find('#autolayout_btn').click( /** * Part 5/8: Initialize the submission of this page */ -$('#id_pipeline_form').submit(buildPipelineSubmit( +$('#form_ctrl').click(buildPipelineSubmit( canvasState, - $('#id_container_pk'), + $containerPk, $memory, $threads, $error )); +$('#id_confirm_save_as').click(buildPipelineSubmit( + canvasState, + $containerPk, + $memory, + $threads, + $error, + $('#id_new_tag'), + $('#id_new_description'), + save_as_dialog +)); +$('#id_cancel_save_as').click(function(e) { + e.preventDefault(); + save_as_dialog.cancel(); +}); /** * Part 6/8: Initialize context menu and register actions diff --git a/kive/container/static/container/drydock.css b/kive/container/static/container/drydock.css index 9d63dff67..9b87eecb4 100755 --- a/kive/container/static/container/drydock.css +++ b/kive/container/static/container/drydock.css @@ -118,7 +118,7 @@ html body { #pipeline_ctrl .form-inline-opts input { display: inline; } -#pipeline_ctrl #form_ctrl { +#pipeline_ctrl #form_ctrls { float: right; margin-top: -1.25em; margin-right: 9em; } @@ -222,5 +222,10 @@ html body { /*# sourceMappingURL=drydock.css.map */ /*# sourceMappingURL=drydock.css.map */ /*# sourceMappingURL=drydock.css.map */ +/*# sourceMappingURL=drydock.css.map */ +/*# sourceMappingURL=drydock.css.map */ +/*# sourceMappingURL=drydock.css.map */ +/*# sourceMappingURL=drydock.css.map */ +/*# sourceMappingURL=drydock.css.map */ /*# sourceMappingURL=drydock.css.map */ \ No newline at end of file diff --git a/kive/container/static/container/io/PipelineApi.d.ts b/kive/container/static/container/io/PipelineApi.d.ts index dae9d27c8..4944f0004 100644 --- a/kive/container/static/container/io/PipelineApi.d.ts +++ b/kive/container/static/container/io/PipelineApi.d.ts @@ -10,6 +10,9 @@ export interface PipelineData { export interface Container { files: string[]; pipeline: PipelineData; + id?: number; + new_tag?: string; + new_description?: string; } export interface PipelineConfig { parent_family: string; diff --git a/kive/container/static/container/io/pipeline_submit.ts b/kive/container/static/container/io/pipeline_submit.ts index 53c3d061a..092c17b22 100644 --- a/kive/container/static/container/io/pipeline_submit.ts +++ b/kive/container/static/container/io/pipeline_submit.ts @@ -2,13 +2,17 @@ import { CanvasState } from "../canvas/drydock"; import { RestApi } from "../rest_api.service"; import { serializePipeline } from "./serializer"; import {PipelineConfig} from "@container/io/PipelineApi"; +import {Dialog} from "@container/pipeline_dialogs"; export function buildPipelineSubmit( canvasState: CanvasState, $container_pk: JQuery, $memory: JQuery, // in MB $threads: JQuery, - $error: JQuery) { + $error: JQuery, + $new_tag?: JQuery, + $new_description?: JQuery, + $save_as_dialog?: Dialog) { if ($container_pk.length === 0) { throw 'Container primary key element could not be found.'; @@ -25,6 +29,12 @@ export function buildPipelineSubmit( if ($error.length === 0) { throw "User error message element could not be found."; } + if ($new_tag !== undefined && $new_tag.length === 0) { + throw "New tag element could not be found."; + } + if ($new_description !== undefined && $new_description.length === 0) { + throw "New description element could not be found."; + } /* * Trigger AJAX transaction on submitting form. @@ -42,12 +52,19 @@ export function buildPipelineSubmit( memory: memory, threads: threads }; + if ($new_tag !== undefined) { + form_data.new_tag = $new_tag.val(); + form_data.new_description = $new_description.val(); + } submitPipelineAjax(container_pk, form_data, $error); } catch (e) { submitError(e, $error); } + if ($save_as_dialog !== undefined) { + $save_as_dialog.hide(); + } }; } @@ -89,9 +106,9 @@ function submitPipelineAjax(container_pk, form_data, $error) { return RestApi.put( '/api/containers/' + container_pk + '/content/', JSON.stringify(form_data), - function() { + function(data) { $(window).off('beforeunload'); - window.location.href = '/container_update/' + container_pk; + window.location.href = '/container_update/' + data.id; }, function (xhr, status, error) { let json = xhr.responseJSON; diff --git a/kive/container/static/container/pipeline_dialogs.ts b/kive/container/static/container/pipeline_dialogs.ts index af90842bb..858796fc3 100644 --- a/kive/container/static/container/pipeline_dialogs.ts +++ b/kive/container/static/container/pipeline_dialogs.ts @@ -79,6 +79,7 @@ export class Dialog { this.show(); // do not bubble up (which would hit document.click again) e.stopPropagation(); + e.preventDefault(); }); // capture mouse/key events jqueryRef.on('click mousedown keydown', e => e.stopPropagation() ); diff --git a/kive/container/templates/container/container_content.html b/kive/container/templates/container/container_content.html index 5663777aa..b93fcd573 100755 --- a/kive/container/templates/container/container_content.html +++ b/kive/container/templates/container/container_content.html @@ -45,16 +45,22 @@
{% csrf_token %} -
- -
-
-
+ + + +
+ +
+
+
+ +
{% include 'container/content_defaults_dialog.tpl.html' with dlg_id="id_defaults_ctrl" %} {% include 'container/content_view_dialog.tpl.html' with dlg_id="id_view_ctrl" %} + {% include 'container/content_save_as_dialog.tpl.html' with dlg_id="id_save_as_ctrl" %}
- \ No newline at end of file diff --git a/kive/container/tests.py b/kive/container/tests.py index 27adfa716..f2c839225 100644 --- a/kive/container/tests.py +++ b/kive/container/tests.py @@ -120,6 +120,7 @@ def test_default_content(self): outputs=[])) content = container.get_content() + content.pop('id') self.assertEqual(expected_content, content) def test_loaded_zip_content(self): @@ -143,6 +144,7 @@ def test_loaded_zip_content(self): outputs=[])) content = container.get_content() + content.pop('id') self.assertEqual(expected_content, content) def test_loaded_tar_content(self): @@ -166,6 +168,7 @@ def test_loaded_tar_content(self): outputs=[])) content = container.get_content() + content.pop('id') self.assertEqual(expected_content, content) def test_revised_content(self): @@ -190,6 +193,7 @@ def test_revised_content(self): outputs=[])) content = container.get_content() + content.pop('id') self.assertEqual(expected_content, content) def test_content_order(self): @@ -215,6 +219,7 @@ def test_content_order(self): outputs=[])) content = container.get_content() + content.pop('id') self.assertEqual(expected_content, content) def test_write_zip_content(self): @@ -233,6 +238,7 @@ def test_write_zip_content(self): container.write_content(expected_content) content = container.get_content() + content.pop('id') self.assertEqual(expected_content, content) self.assertEqual(expected_apps_count, container.apps.count()) @@ -253,6 +259,7 @@ def test_write_tar_content(self): container.write_content(expected_content) content = container.get_content() + content.pop('id') self.assertEqual(expected_content, content) self.assertEqual(expected_apps_count, container.apps.count()) @@ -276,6 +283,7 @@ def test_rewrite_content(self): warnings.showwarning = lambda message, *args: self.fail(message) container.write_content(expected_content) content = container.get_content() + content.pop('id') self.assertEqual(expected_content, content) @@ -607,6 +615,7 @@ def test_get_content(self): request1 = self.factory.get(self.content_path) force_authenticate(request1, user=self.kive_user) content = self.content_view(request1, pk=self.detail_pk).data + content.pop('id') self.assertEqual(expected_content, content) @@ -621,7 +630,8 @@ def test_put_content(self): threads=3), inputs=[], steps=[], - outputs=[])) + outputs=[]), + id=self.test_container.id) request1 = self.factory.put(self.content_path, expected_content, @@ -653,6 +663,89 @@ def test_put_bad_content(self): self.assertEqual(expected_content, content) self.assertEqual(400, response.status_code) + def test_write_content_copy(self): + self.test_container.file_type = Container.ZIP + self.test_container.tag = 'v1' + self.test_container.description = 'v1 description' + self.test_container.file.save( + 'test.zip', + ContentFile(self.create_zip_content().getvalue())) + put_content = dict(pipeline=dict(default_config=dict(memory=400, + threads=3), + inputs=[], + steps=[], + outputs=[]), + new_tag='v2') + expected_content = dict(files=["bar.txt", "foo.txt"], + pipeline=dict(default_config=dict(memory=400, + threads=3), + inputs=[], + steps=[], + outputs=[])) # id not shown here + + request1 = self.factory.put(self.content_path, + put_content, + format='json') + force_authenticate(request1, user=self.kive_user) + content = self.content_view(request1, pk=self.detail_pk).data + + new_container_id = content.pop('id') + self.assertNotEqual(self.test_container.id, new_container_id) # New record + self.assertEqual(expected_content, content) + new_container = Container.objects.get(id=new_container_id) + self.assertEqual('v2', new_container.tag) + self.assertEqual('v1 description', new_container.description) + self.assertNotEqual(self.test_container.file.path, new_container.file.path) + + def test_write_content_copy_with_description(self): + self.test_container.file_type = Container.ZIP + self.test_container.tag = 'v1' + self.test_container.description = 'v1 description' + self.test_container.file.save( + 'test.zip', + ContentFile(self.create_zip_content().getvalue())) + put_content = dict(pipeline=dict(default_config=dict(memory=400, + threads=3), + inputs=[], + steps=[], + outputs=[]), + new_tag='v2', + new_description='v2 description') + + request1 = self.factory.put(self.content_path, + put_content, + format='json') + force_authenticate(request1, user=self.kive_user) + content = self.content_view(request1, pk=self.detail_pk).data + + new_container = Container.objects.get(id=content['id']) + self.assertEqual('v2', new_container.tag) + self.assertEqual('v2 description', new_container.description) + + def test_write_content_duplicate_tag(self): + self.test_container.file_type = Container.ZIP + self.test_container.tag = 'v1' + self.test_container.file.save( + 'test.zip', + ContentFile(self.create_zip_content().getvalue())) + bad_content = dict(pipeline=dict(default_config=dict(memory=400, + threads=3), + inputs=[], + steps=[], + outputs=[]), + new_tag='v1') # Duplicate tag! + expected_content = dict(new_tag=["Tag already exists."]) + + request1 = self.factory.put(self.content_path, + bad_content, + format='json') + force_authenticate(request1, user=self.kive_user) + response = self.content_view(request1, pk=self.detail_pk) + content = response.data + + self.assertEqual(expected_content, content) + self.assertEqual(400, response.status_code) + def test_create_tar_with_app(self): tar_bytes = create_valid_tar_content() request2 = self.factory.post(self.list_path,