Skip to content

Commit

Permalink
Add container apps as part of #739.
Browse files Browse the repository at this point in the history
  • Loading branch information
donkirkby committed Nov 23, 2018
1 parent 42e8470 commit bc72bcb
Show file tree
Hide file tree
Showing 21 changed files with 987 additions and 26 deletions.
Binary file modified docs/models/archive.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/models/container.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 48 additions & 2 deletions kive/container/ajax.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
from rest_framework.decorators import action
from rest_framework.response import Response

from container.models import ContainerFamily, Container
from container.serializers import ContainerFamilySerializer, ContainerSerializer
from container.models import ContainerFamily, Container, ContainerApp
from container.serializers import ContainerFamilySerializer, ContainerSerializer, \
ContainerAppSerializer
from kive.ajax import CleanCreateModelMixin, RemovableModelViewSet, \
SearchableModelMixin, IsDeveloperOrGrantedReadOnly, StandardPagination
from metadata.models import AccessControl
Expand Down Expand Up @@ -141,3 +142,48 @@ def download(self, request, pk=None):
finally:
container.file.close()
return response


class ContainerAppViewSet(CleanCreateModelMixin,
RemovableModelViewSet,
SearchableModelMixin):
""" An app within a Singularity container.
Query parameters:
* is_granted - true For administrators, this limits the list to only include
records that the user has been explicitly granted access to, via their
parent containers. For other users, this has no effect.
* filters[n][key]=x&filters[n][val]=y - Apply different filters to the
search. n starts at 0 and increases by 1 for each added filter.
Some filters just have a key and ignore the val value. The possible
filters are listed below.
* filters[n][key]=container_id&filters[n][val]=match - parent container's
id equals the value
* filters[n][key]=smart&filters[n][val]=match - app name or description
contains the value (case insensitive)
* filters[n][key]=name&filters[n][val]=match - app name contains the
value (case insensitive)
* filters[n][key]=description&filters[n][val]=match - description contains
the value (case insensitive)
"""
queryset = ContainerApp.objects.all()
serializer_class = ContainerAppSerializer
permission_classes = (permissions.IsAuthenticated, IsDeveloperOrGrantedReadOnly)
pagination_class = StandardPagination
filters = dict(
container_id=lambda queryset, value: queryset.filter(
container_id=value),
smart=lambda queryset, value: queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)),
name=lambda queryset, value: queryset.filter(
name__icontains=value),
description=lambda queryset, value: queryset.filter(
description__icontains=value))

def filter_granted(self, queryset):
""" Apps don't have permissions, so filter by parent containers. """
granted_containers = Container.filter_by_user(self.request.user)

return queryset.filter(container_id__in=granted_containers)
23 changes: 22 additions & 1 deletion kive/container/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django import forms
from django.forms.widgets import TextInput

from container.models import ContainerFamily, Container
from container.models import ContainerFamily, Container, ContainerApp
from metadata.forms import PermissionsForm


Expand All @@ -22,3 +23,23 @@ class ContainerUpdateForm(ContainerForm):
def __init__(self, *args, **kwargs):
super(ContainerUpdateForm, self).__init__(*args, **kwargs)
self.fields.pop('file')


class ContainerAppForm(forms.ModelForm):
inputs = forms.CharField(
widget=TextInput(attrs=dict(size=50)),
required=False,
help_text='A space-separated list of argument names. You can also use '
'prefixes and suffixes for different kinds of arguments: '
'--optional, multiple*, and --optional_multiple*.')
outputs = forms.CharField(
widget=TextInput(attrs=dict(size=50)),
required=False,
help_text='A space-separated list of argument names. You can also use '
'prefixes and suffixes for different kinds of arguments: '
'--optional, folder/, and --optional_folder/.')

class Meta(object):
model = ContainerApp
fields = ['name', 'description', 'inputs', 'outputs']
widgets = dict(description=forms.Textarea(attrs=dict(cols=50, rows=10)))
39 changes: 39 additions & 0 deletions kive/container/migrations/0004_containerapp_containerargument.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.15 on 2018-11-23 19:10
from __future__ import unicode_literals

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('container', '0003_reverse_ordering'),
]

operations = [
migrations.CreateModel(
name='ContainerApp',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, help_text=b'Leave blank for default', max_length=60)),
('description', models.CharField(blank=True, max_length=1000, verbose_name=b'Description')),
('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='apps', to='container.Container')),
],
options={
'ordering': ('name',),
},
),
migrations.CreateModel(
name='ContainerArgument',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=60)),
('position', models.IntegerField(blank=True, help_text=b'Position in the arguments (gaps and duplicates are allowed). Leave position blank to pass as an option with --name.', null=True)),
('type', models.CharField(choices=[(b'I', b'Input'), (b'O', b'Output')], max_length=1)),
('allow_multiple', models.BooleanField(default=False, help_text=b'True for optional inputs that accept multiple datasets and outputs that just collect all files written to a directory')),
('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='arguments', to='container.ContainerApp')),
],
),
]
117 changes: 117 additions & 0 deletions kive/container/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,123 @@ def remove(self):
remove_helper(removal_plan)


class ContainerApp(models.Model):
container = models.ForeignKey(Container, related_name="apps")
name = models.CharField(max_length=maxlengths.MAX_NAME_LENGTH,
help_text="Leave blank for default",
blank=True)
description = models.CharField('Description',
blank=True,
max_length=maxlengths.MAX_DESCRIPTION_LENGTH)
arguments = None # Filled in later from child table.
objects = None # Filled in later by Django.

class Meta:
ordering = ('name',)

@property
def inputs(self):
return self._format_arguments(ContainerArgument.INPUT)

@property
def outputs(self):
return self._format_arguments(ContainerArgument.OUTPUT)

def _format_arguments(self, argument_type):
arguments = self.arguments.filter(type=argument_type)
optionals = [argument
for argument in arguments
if argument.position is None]
positionals = [argument
for argument in arguments
if argument.position is not None]
terms = [argument.formatted for argument in optionals]
if (argument_type == ContainerArgument.INPUT and
any(argument.allow_multiple for argument in optionals)):
terms.append('--')
terms.extend(argument.formatted for argument in positionals)
return ' '.join(terms)

def write_inputs(self, formatted):
self._write_arguments(ContainerArgument.INPUT, formatted)

def write_outputs(self, formatted):
self._write_arguments(ContainerArgument.OUTPUT, formatted)

def _write_arguments(self, argument_type, formatted):
self.arguments.filter(type=argument_type).delete()
expected_multiples = {ContainerArgument.INPUT: '*',
ContainerArgument.OUTPUT: '/'}
for position, term in enumerate(formatted.split(), 1):
if term == '--':
continue
match = re.match(r'(--)?(\w+)([*/])?$', term)
if match is None:
raise ValueError('Invalid argument name: {}'.format(term))
if match.group(1):
position = None
if not match.group(3):
allow_multiple = False
elif match.group(3) == expected_multiples[argument_type]:
allow_multiple = True
else:
raise ValueError('Invalid argument name: {}'.format(term))
self.arguments.create(name=match.group(2),
position=position,
allow_multiple=allow_multiple,
type=argument_type)

def get_absolute_url(self):
return reverse('container_app_update', kwargs=dict(pk=self.pk))

@transaction.atomic
def build_removal_plan(self, removal_accumulator=None):
""" Make a manifest of objects to remove when removing this. """
removal_plan = removal_accumulator or empty_removal_plan()
assert self not in removal_plan["ContainerApps"]
removal_plan["ContainerApps"].add(self)

return removal_plan

@transaction.atomic
def remove(self):
removal_plan = self.build_removal_plan()
remove_helper(removal_plan)


class ContainerArgument(models.Model):
INPUT = 'I'
OUTPUT = 'O'
TYPES = ((INPUT, 'Input'),
(OUTPUT, 'Output'))

app = models.ForeignKey(ContainerApp, related_name="arguments")
name = models.CharField(max_length=maxlengths.MAX_NAME_LENGTH)
position = models.IntegerField(
null=True,
blank=True,
help_text="Position in the arguments (gaps and duplicates are allowed). "
"Leave position blank to pass as an option with --name.")
type = models.CharField(max_length=1, choices=TYPES)
allow_multiple = models.BooleanField(
default=False,
help_text="True for optional inputs that accept multiple datasets and "
"outputs that just collect all files written to a directory")

def __repr__(self):
return 'ContainerArgument(name={!r})'.format(self.name)

@property
def formatted(self):
text = self.name
if self.position is None:
# noinspection PyTypeChecker
text = '--' + text
if self.allow_multiple:
text += '*' if self.type == ContainerArgument.INPUT else '/'
return text


@receiver(models.signals.post_delete, sender=Container)
def delete_container_file(instance, **_kwargs):
if instance.file:
Expand Down
30 changes: 29 additions & 1 deletion kive/container/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from rest_framework import serializers
from rest_framework.fields import URLField

from container.models import ContainerFamily, Container
from container.models import ContainerFamily, Container, ContainerApp
from kive.serializers import AccessControlSerializer


Expand Down Expand Up @@ -64,3 +64,31 @@ class Meta:
'users_allowed',
'groups_allowed',
'removal_plan')


class ContainerAppSerializer(serializers.ModelSerializer):
absolute_url = URLField(source='get_absolute_url', read_only=True)
container = serializers.HyperlinkedRelatedField(
view_name='container-detail',
lookup_field='pk',
queryset=Container.objects.all())
removal_plan = serializers.HyperlinkedIdentityField(
view_name='containerapp-removal-plan')

class Meta:
model = ContainerApp
fields = ('id',
'url',
'absolute_url',
'container',
'name',
'description',
'inputs',
'outputs',
'removal_plan')

def save(self, **kwargs):
app = super(ContainerAppSerializer, self).save(**kwargs)
app.write_inputs(self.initial_data.get('inputs', ''))
app.write_outputs(self.initial_data.get('outputs', ''))
return app
21 changes: 21 additions & 0 deletions kive/container/static/container/ContainerAppTable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
(function(permissions) {//dependent on PermissionsTable class
"use strict";
permissions.ContainerAppTable = function($table, is_user_admin, $navigation_links) {
permissions.PermissionsTable.call(this, $table, is_user_admin, $navigation_links);
this.list_url = "/api/containerapps/";
this.registerLinkColumn("Name", "", "name", "absolute_url");
this.registerColumn("Description", "description");
this.registerColumn("Inputs", "inputs");
this.registerColumn("Outputs", "outputs");
};
permissions.ContainerAppTable.prototype = Object.create(permissions.PermissionsTable.prototype);
permissions.ContainerAppTable.prototype.extractRows = function(response) {
var rows = response.results;
for(var i in rows) {
if (rows[i].name === '') {
rows[i].name = '[default]';
}
}
return rows;
};
})(permissions);
64 changes: 64 additions & 0 deletions kive/container/templates/container/container_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,30 @@
<script src="/static/portal/jquery-2.0.3.min.js"></script>
<script src="/static/portal/noxss.js"></script>
<script src="/static/portal/permissions.js"></script>
<script src="/static/portal/ajaxsearchfilter.js"></script>
<script src="/static/portal/helptext.js"></script>
<script src="/static/container/ContainerAppTable.js"></script>
<script type="text/javascript">

$(function(){
var table = new permissions.ContainerAppTable(
$("#container_apps"),
{{ is_user_admin|lower }},
$(".navigation_links")
);
var asf = new AjaxSearchFilter(table, $("#asf"));

// This adds a filter for the current object.
table.filterSet.add("container_id", {{container.id}}, true).hide();

asf.reloadTable();
});
</script>
{% endblock %}

{% block stylesheets %}
<link rel="stylesheet" href="/static/portal/permissions.css"/>
<link rel="stylesheet" href="/static/portal/search.css"/>
{% endblock %}

{% block widget_media %}
Expand Down Expand Up @@ -58,4 +81,45 @@ <h2>Container</h2>
<input type="submit" value="Submit" />
</form>

{% if object %}
<h3>Apps in the Container:</h3>
<a class="button" href="app_add">+&ensp;<span class="button-lbl">Create
a new app</span></a>

<div id="asf">
<form class="asf-form">
<div class="asf-main-search">
<div class="asf-active-filters"></div>
<input type="text" name="smart" class="asf-search-field">
<input type="button" class="advanced ctrl" value="Advanced">
<input type="submit" value="Filter" class="asf-search-button">
</div>
</form>
<form class="asf-advanced-form">
<input type="button" class="advanced ctrl" value="Advanced">
<h4>Advanced search</h4>
<div class="asf-field-container">
<label for="search-name">Name</label><input
name="name"
id="search-name"
type="text"
class="asf-search-fixed-field">
</div>
<div class="asf-field-container">
<label for="search-description">Description</label><input
name="description"
id="search-description"
type="text"
class="asf-search-fixed-field">
</div>
<input type="submit" value="Filter" class="asf-search-button">
</form>
</div>

<div class="navigation_links"></div>

<div class="dev_results">
<table id="container_apps"></table>
</div>
{% endif %}
{% endblock %}
Loading

0 comments on commit bc72bcb

Please sign in to comment.