Skip to content
Snippets Groups Projects
Commit e768e789 authored by André Anjos's avatar André Anjos :speech_balloon:
Browse files

Merge branch 'cherry-pick-df5d3aec' into 'scheduler'

Merge branch 'plotters' into 'scheduler'

[Plotters] It's possible to see the plotters available with various information on the plotter

Plotters view added to the platform. 
Contains:
- [x]  Plotters list and tables (and possibility to edit as admin)
- [x]  Plotter's view (public + user)
- [x]  Contains plotter's code and parameters + description
- [x]  Possibility to see reports containing the plotter (user + public)
- [x]  Added Plotters to statistics panel and activity stream user panel
- [x] Updated parts of the view and API
- [x] Added Plotters to drop down menus

See merge request !188

See merge request !189
parents 2f842952 c6fc65e8
No related branches found
No related tags found
2 merge requests!194Scheduler,!189Merge branch 'plotters' into 'scheduler'
Pipeline #
Showing
with 1827 additions and 4 deletions
...@@ -28,14 +28,27 @@ ...@@ -28,14 +28,27 @@
from ..common.api import ListContributionView from ..common.api import ListContributionView
from .models import Plotter, PlotterParameter, DefaultPlotter from .models import Plotter, PlotterParameter, DefaultPlotter
from .serializers import PlotterSerializer, PlotterParameterSerializer, DefaultPlotterSerializer from .serializers import PlotterSerializer, PlotterParameterSerializer, DefaultPlotterSerializer
from .serializers import PlotterAllSerializer, PlotterCreationSerializer, FullPlotterSerializer
from .serializers import PlotterParameterAllSerializer, PlotterParameterCreationSerializer, FullPlotterParameterSerializer
from django.db.models import Q from django.db.models import Q
from ..code.api import ShareCodeView, RetrieveUpdateDestroyCodeView
from ..code.serializers import CodeDiffSerializer
from ..common.api import (CheckContributionNameView, ShareView, DiffView,
ListContributionView, ListCreateContributionView, RetrieveUpdateDestroyContributionView)
from ..common.utils import validate_restructuredtext, ensure_html
from ..common.responses import BadRequestResponse
from rest_framework import generics from rest_framework import generics
from rest_framework import views from rest_framework import views
from rest_framework import permissions from rest_framework import permissions
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from django.shortcuts import get_object_or_404
from django.utils import six
from django.core.exceptions import ValidationError
class ListPlotterView(ListContributionView): class ListPlotterView(ListContributionView):
""" """
...@@ -84,8 +97,271 @@ class ListDefaultPlotterView(generics.ListAPIView): ...@@ -84,8 +97,271 @@ class ListDefaultPlotterView(generics.ListAPIView):
model = DefaultPlotter model = DefaultPlotter
serializer_class = DefaultPlotterSerializer serializer_class = DefaultPlotterSerializer
def list(self, request): def list(self, request):
queryset = DefaultPlotter.objects.all() queryset = self.get_queryset()
serializer = DefaultPlotterSerializer(queryset, many=True, context={'request': request}) serializer = DefaultPlotterSerializer(queryset, many=True, context={'request': request})
return Response(serializer.data) return Response(serializer.data)
def get_queryset(self):
queryset = DefaultPlotter.objects.all()
return queryset
#----------------------------------------------------------
class CheckPlotterNameView(CheckContributionNameView):
"""
This view sanitizes a Plotter name and
checks whether it is already used.
"""
model = Plotter
#----------------------------------------------------------
class SharePlotterView(ShareCodeView):
"""
This view allows to share a plotter with
other users and/or teams
"""
model = Plotter
#----------------------------------------------------------
class ListCreatePlottersView(ListCreateContributionView):
"""
Read/Write end point that list the plotters available
from a given author and allows the creation of new plotters
"""
model = Plotter
serializer_class = PlotterAllSerializer
writing_serializer_class = PlotterCreationSerializer
namespace = 'api_plotters'
#----------------------------------------------------------
class RetrieveUpdateDestroyPlottersView(RetrieveUpdateDestroyCodeView):
"""
Read/Write/Delete endpoint for a given plotter
"""
model = Plotter
serializer_class = FullPlotterSerializer
def get_queryset(self):
plotter_author = self.kwargs.get('author_name')
plotter_name = self.kwargs.get('object_name')
plotter_version = self.kwargs.get('version')
#plotter = get_object_or_404(Plotter, author__username=plotter_author, name=plotter_name, version=plotter_version)
plotter = Plotter.objects.filter(author__username=plotter_author, name=plotter_name, version=plotter_version)
self.check_object_permissions(self.request, plotter)
return plotter
#def do_update(self, request, author_name, object_name, version=None):
# modified, algorithm = super(RetrieveUpdateDestroyPlottersView, self).do_update(request, author_name, object_name, version)
# if modified:
# # Delete existing experiments using the algorithm (code changed)
# experiments = list(set(map(lambda x: x.experiment,
# algorithm.blocks.iterator())))
# for experiment in experiments: experiment.delete()
# return modified, algorithm
##----------------------------------------------------------
#
#
#class DiffPlotterView(DiffView):
# """
# This view shows the differences between two algorithms
# """
# model = Algorithm
# serializer_class = CodeDiffSerializer
#----------------------------------------------------------
class CheckPlotterParameterNameView(CheckContributionNameView):
"""
This view sanitizes a PlotterParameter name and
checks whether it is already used.
"""
model = PlotterParameter
#----------------------------------------------------------
class SharePlotterParameterView(ShareView):
"""
This view allows to share a PlotterParameter with
other users and/or teams
"""
model = PlotterParameter
#----------------------------------------------------------
class ListPlotterParametersView(ListCreateContributionView):
"""
List all available PlotterParameters
"""
model = PlotterParameter
serializer_class = FullPlotterParameterSerializer
writing_serializer_class = PlotterParameterCreationSerializer
namespace = 'api_plotters'
#def get_queryset(self):
# # get all plotterparameter accessible for user (private+public)
# queryset = PlotterParameter.objects.for_user(self.request.user, True)
# return queryset
#----------------------------------------------------------
#class ListCreatePlotterParametersView(ListCreateContributionView):
# """
# Read/Write end point that list the PlotterParameters available
# from a given author and allows the creation of new PlotterParameters
# """
# model = PlotterParameter
# serializer_class = FullPlotterParameterSerializer
# namespace = 'api_plotterparameters'
#----------------------------------------------------------
class RetrieveUpdateDestroyPlotterParametersView(RetrieveUpdateDestroyContributionView):
"""
Read/Write/Delete endpoint for a given PlotterParameter
"""
model = PlotterParameter
serializer_class = FullPlotterParameterSerializer
def put(self, request, author_name, object_name, version=None):
if version is None:
return BadRequestResponse('A version number must be provided')
try:
data = request.data
except ParseError as e:
raise serializers.ValidationError({'data': str(e)})
else:
if not data:
raise serializers.ValidationError({'data': 'Empty'})
if data.has_key('short_description'):
if not(isinstance(data['short_description'], six.string_types)):
raise serializers.ValidationError({'short_description', 'Invalid short_description data'})
short_description = data['short_description']
else:
short_description = None
if data.has_key('description'):
if not(isinstance(data['description'], six.string_types)):
raise serializers.ValidationError({'description': 'Invalid description data'})
description = data['description']
try:
validate_restructuredtext(description)
except ValidationError as errors:
raise serializers.ValidationError({'description': [error for error in errors]})
else:
description = None
if data.has_key('strict'):
strict = data['strict']
else:
strict = True
plotter = None
if data.has_key('plotter'):
try:
if isinstance(data['plotter'], int):
plotter = Plotter.objects.get(id=data['plotter'])
else:
return BadRequestResponse('A valid plotter id number must be provided')
except:
return BadRequestResponse('A valid plotter id number must be provided')
if (short_description is not None) and (len(short_description) > self.model._meta.get_field('short_description').max_length):
raise serializers.ValidationError({'short_description': 'Short description too long'})
# Process the query string
if request.GET.has_key('fields'):
fields_to_return = request.GET['fields'].split(',')
else:
# Available fields (not returned by default):
# - html_description
fields_to_return = ['errors']
# Retrieve the plotterparameter
dbplotterparameter = get_object_or_404(PlotterParameter,
author__username__iexact=author_name,
name__iexact=object_name,
version=version)
# Check that the object can still be modified (if applicable, the
# documentation can always be modified)
if not dbplotterparameter.modifiable():
raise PermissionDenied("The {} isn't modifiable anymore (either shared with someone else, or needed by an attestation)".format(dbplotterparameter.model_name()))
errors = None
# Modification of the short_description
if (short_description is not None):
dbplotterparameter.short_description = short_description
# Modification of the description
if description is not None:
dbplotterparameter.description = description
# Modification of the plotter
if plotter is not None:
dbplotterparameter.plotter = plotter
# Save the plotterparameter model
try:
dbplotterparameter.save()
except Exception as e:
return BadRequestResponse(str(e))
# Nothing to return?
if len(fields_to_return) == 0:
return Response(status=204)
result = {}
# Retrieve the errors (if necessary)
if 'errors' in fields_to_return:
if errors:
result['errors'] = errors
else:
result['errors'] = ''
# Retrieve the description in HTML format (if necessary)
if 'html_description' in fields_to_return:
description = dbplotterparameter.description
if len(description) > 0:
result['html_description'] = ensure_html(description)
else:
result['html_description'] = ''
return Response(result)
...@@ -33,6 +33,44 @@ urlpatterns = [ ...@@ -33,6 +33,44 @@ urlpatterns = [
url(r'^$', api.ListPlotterView.as_view(), name='all'), url(r'^$', api.ListPlotterView.as_view(), name='all'),
url(r'^format/(?P<author_name>\w+)/(?P<dataformat_name>[a-zA-Z0-9_\-]+)/(?P<version>\d+)/$', api.ListFormatPlotterView.as_view(), name='object'), url(r'^format/(?P<author_name>\w+)/(?P<dataformat_name>[a-zA-Z0-9_\-]+)/(?P<version>\d+)/$', api.ListFormatPlotterView.as_view(), name='object'),
url(r'^plotterparameter/$', api.ListPlotterParameterView.as_view(), name='all_plotterparameter'), url(r'^plotterparameter/$', api.ListPlotterParameterView.as_view(), name='all_plotterparameter'),
url(r'^plotterparameters/(?P<author_name>\w+)/(?P<object_name>[a-zA-Z0-9_\-]+)/(?P<version>\d+)/$', api.RetrieveUpdateDestroyPlotterParametersView.as_view(), name='view'),
url(r'^plotterparameters/(?P<author_name>\w+)/$', api.ListPlotterParametersView.as_view(), name='view'),
url(r'^plotterparameter/(?P<author_name>\w+)/(?P<dataformat_name>[a-zA-Z0-9_\-]+)/(?P<version>\d+)/$', api.ListPlotterParameterView.as_view(), name='plotterparameter'), url(r'^plotterparameter/(?P<author_name>\w+)/(?P<dataformat_name>[a-zA-Z0-9_\-]+)/(?P<version>\d+)/$', api.ListPlotterParameterView.as_view(), name='plotterparameter'),
url(r'^defaultplotters/$', api.ListDefaultPlotterView.as_view(), name='all_defaultplotters'), url(r'^defaultplotters/$', api.ListDefaultPlotterView.as_view(), name='all_defaultplotters'),
#url(r'^$',
# api.ListPlottersView.as_view(),
# name='all_plotters',
# ),
url(r'^check_name/$',
api.CheckPlotterNameView.as_view(),
name='check_name',
),
#url(r'^diff/(?P<author1>\w+)/(?P<name1>[-\w]+)/(?P<version1>\d+)/(?P<author2>\w+)/(?P<name2>[-\w]+)/(?P<version2>\d+)/$',
# api.DiffPlotterView.as_view(),
# name='diff',
# ),
url(r'^(?P<author_name>\w+)/(?P<object_name>[-\w]+)/(?P<version>\d+)/share/$',
api.SharePlotterView.as_view(),
name='share',
),
url(r'^(?P<author_name>\w+)/$',
api.ListCreatePlottersView.as_view(),
name='list_create',
),
url(r'^(?P<author_name>\w+)/(?P<object_name>[-\w]+)/(?P<version>\d+)/$',
api.RetrieveUpdateDestroyPlottersView.as_view(),
name='object',
),
#url(r'^(?P<author_name>\w+)/(?P<object_name>[-\w]+)/$',
# api.RetrieveUpdateDestroyPlottersView.as_view(),
# name='object',
# ),
] ]
...@@ -27,6 +27,7 @@ ...@@ -27,6 +27,7 @@
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User from django.contrib.auth.models import User
...@@ -234,11 +235,11 @@ class Plotter(Code): ...@@ -234,11 +235,11 @@ class Plotter(Code):
def modifiable(self): def modifiable(self):
"""Can be modified if nobody points at me""" """Can be modified if nobody points at me"""
return super(Plotter, self).modifiable() and ((self.search_uses.count() + self.defaults.count()) == 0) return super(Plotter, self).modifiable() and (self.defaults.count() == 0)
def deletable(self): def deletable(self):
"""Can be deleted if nobody points at me""" """Can be deleted if nobody points at me"""
return super(Plotter, self).deletable() and ((self.search_uses.count() + self.defaults.count()) == 0) return super(Plotter, self).deletable() and (self.defaults.count() == 0)
def core(self): def core(self):
return beat.core.plotter.Plotter(settings.PREFIX, self.fullname()) return beat.core.plotter.Plotter(settings.PREFIX, self.fullname())
...@@ -247,6 +248,19 @@ class Plotter(Code): ...@@ -247,6 +248,19 @@ class Plotter(Code):
def core_format(self): def core_format(self):
return beat.core.dataformat.DataFormat(settings.PREFIX, self.dataformat.fullname()) return beat.core.dataformat.DataFormat(settings.PREFIX, self.dataformat.fullname())
def json_parameters(self):
return self.declaration['parameters'] if self.declaration['parameters'] else {}
#_____ Utilities __________
def get_absolute_url(self):
return reverse(
'plotters:view',
args=(self.author.username, self.name, self.version,),
)
class PlotterParameter(Contribution): class PlotterParameter(Contribution):
......
...@@ -25,10 +25,18 @@ ...@@ -25,10 +25,18 @@
# # # #
############################################################################### ###############################################################################
from ..common.serializers import DynamicFieldsSerializer, ContributionSerializer from ..common.serializers import DynamicFieldsSerializer, ContributionSerializer, ContributionCreationSerializer
from .models import Plotter, PlotterParameter, DefaultPlotter from .models import Plotter, PlotterParameter, DefaultPlotter
from rest_framework import serializers from rest_framework import serializers
from ..code.serializers import CodeSerializer, CodeCreationSerializer
from ..libraries.serializers import LibraryReferenceSerializer
from ..dataformats.serializers import ReferencedDataFormatSerializer
import beat.core.plotter
import simplejson as json
class PlotterSerializer(ContributionSerializer): class PlotterSerializer(ContributionSerializer):
dataformat = serializers.CharField(source="dataformat.fullname") dataformat = serializers.CharField(source="dataformat.fullname")
...@@ -59,3 +67,129 @@ class DefaultPlotterSerializer(DynamicFieldsSerializer): ...@@ -59,3 +67,129 @@ class DefaultPlotterSerializer(DynamicFieldsSerializer):
default_fields = [ default_fields = [
'dataformat', 'plotter', 'parameter', 'dataformat', 'plotter', 'parameter',
] ]
#----------------------------------------------------------
class PlotterCreationSerializer(CodeCreationSerializer):
class Meta(CodeCreationSerializer.Meta):
model = Plotter
beat_core_class = beat.core.plotter
#----------------------------------------------------------
class PlotterAllSerializer(CodeSerializer):
dataformat = serializers.SerializerMethodField()
declaration_file = serializers.SerializerMethodField()
description_file = serializers.SerializerMethodField()
source_code_file = serializers.SerializerMethodField()
referenced_libraries = LibraryReferenceSerializer(many=True)
class Meta(CodeSerializer.Meta):
model = Plotter
#----------------------------------------------------------
class FullPlotterSerializer(PlotterAllSerializer):
class Meta(PlotterAllSerializer.Meta):
default_fields = PlotterAllSerializer.Meta.default_fields + PlotterAllSerializer.Meta.extra_fields
#----------------------------------------------------------
class PlotterParameterCreationFailedException(Exception):
pass
class PlotterParameterCreationSerializer(ContributionCreationSerializer):
class Meta(ContributionCreationSerializer.Meta):
model = PlotterParameter
#beat_core_class = beat.core.PlotterParameter
def create(self, validated_data):
plotterparameter = None
if not validated_data.has_key("name"):
raise serializers.ValidationError('No name provided')
try:
plotterparameter = PlotterParameter.objects.get(author=self.context['request'].user, name=validated_data['name'])
except:
pass
if plotterparameter is not None:
raise serializers.ValidationError('A plotterparameter with this name already exists')
validated_data['data'] = {}
plotterparameter = PlotterParameter.objects.create(**validated_data)
if plotterparameter is None:
raise PlotterParameterCreationFailedException()
return plotterparameter
#----------------------------------------------------------
class PlotterParameterAllSerializer(ContributionSerializer):
data = serializers.SerializerMethodField()
description = serializers.SerializerMethodField()
plotter = serializers.SerializerMethodField()
class Meta(ContributionSerializer.Meta):
model = PlotterParameter
#def get_referencing_experiments(self, obj):
# user = self.context.get('user')
# experiments = obj.experiments.for_user(user, True).order_by('-creation_date')
# serializer = ExperimentSerializer(experiments, many=True)
# referencing_experiments = serializer.data
# # Put the pending experiments first
# ordered_result = filter(lambda x: x['creation_date'] is None, referencing_experiments)
# ordered_result += filter(lambda x: x['creation_date'] is not None, referencing_experiments)
# return ordered_result
#def get_new_experiment_url(self, obj):
# return obj.get_new_experiment_url()
#----------------------------------------------------------
class FullPlotterParameterSerializer(PlotterParameterAllSerializer):
class Meta(PlotterParameterAllSerializer.Meta):
#exclude = ['declaration']
exclude = []
#default_fields = PlotterParameterAllSerializer.Meta.default_fields + PlotterParameterAllSerializer.Meta.extra_fields
default_fields = ['id', 'accessibility', 'modifiable', 'deletable', 'is_owner', 'name', 'fork_of', 'last_version', 'previous_version', 'short_description', 'description', 'version', 'creation_date', 'data', 'plotter']
def get_data(self, obj):
return json.loads(obj.data)
def get_plotter(self, obj):
if obj.plotter is not None:
return obj.plotter.fullname()
else:
return "undefined plotter"
#def get_plotter(self, obj):
# return obj.author.username
#"accessibility": "public",
#"modifiable": true,
#"deletable": true,
#"is_owner": false,
#"name": "plot/bar/1",
#"fork_of": null,
#"last_version": true,
#"previous_version": null,
#"short_description": "Default parameters for bar plots",
#"description": "Raw content",
#"version": 1,
#"creation_date": "2015-09-03T16:55:47.620000",
{% extends "base.html" %}
{% comment %}
* Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/
* Contact: beat.support@idiap.ch
*
* This file is part of the beat.web module of the BEAT platform.
*
* Commercial License Usage
* Licensees holding valid commercial BEAT licenses may use this file in
* accordance with the terms contained in a written agreement between you
* and Idiap. For further information contact tto@idiap.ch
*
* Alternatively, this file may be used under the terms of the GNU Affero
* Public License version 3 as published by the Free Software and appearing
* in the file LICENSE.AGPL included in the packaging of this file.
* The BEAT platform is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
*
* You should have received a copy of the GNU Affero Public License along
* with the BEAT platform. If not, see http://www.gnu.org/licenses/.
{% endcomment %}
{% load plotter_tags %}
{% load ui_tags %}
{% block title %}{{ block.super }} - {% if author.is_anonymous %}Public{% else %}{{ author.username }}'s{% endif %} Plotters{% endblock %}
{% block content %}
{% list_tabs author "plotters" %}
{% plotter_table objects owner "plotter-list" %}
{% endblock %}
{% comment %}
* Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/
* Contact: beat.support@idiap.ch
*
* This file is part of the beat.web module of the BEAT platform.
*
* Commercial License Usage
* Licensees holding valid commercial BEAT licenses may use this file in
* accordance with the terms contained in a written agreement between you
* and Idiap. For further information contact tto@idiap.ch
*
* Alternatively, this file may be used under the terms of the GNU Affero
* Public License version 3 as published by the Free Software and appearing
* in the file LICENSE.AGPL included in the packaging of this file.
* The BEAT platform is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
*
* You should have received a copy of the GNU Affero Public License along
* with the BEAT platform. If not, see http://www.gnu.org/licenses/.
{% endcomment %}
{% load plotter_tags %}
<div class="btn-group btn-group-sm action-buttons pull-right">
<!-- Edit, as admin -->
{% if request.user.is_staff %}
<a class="btn btn-default btn-edit" href="{% url 'admin:plotters_plotter_change' object.id %}" data-toggle="tooltip" data-placement="bottom" title="Edit as admin"><i class="fa fa-cogs fa-lg"></i></a>
{% endif %}
{% if open_source and not request.user.is_anonymous %}
<!-- Fork button, needs to be logged in -->
<a class="btn btn-default btn-fork" href="{% url 'plotters:fork' object.author.username object.name object.version %}" data-toggle="tooltip" data-placement="bottom" title="Fork"><i class="fa fa-code-fork fa-lg"></i></a>
{% endif %}
</div>
{% comment %}
* Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/
* Contact: beat.support@idiap.ch
*
* This file is part of the beat.web module of the BEAT platform.
*
* Commercial License Usage
* Licensees holding valid commercial BEAT licenses may use this file in
* accordance with the terms contained in a written agreement between you
* and Idiap. For further information contact tto@idiap.ch
*
* Alternatively, this file may be used under the terms of the GNU Affero
* Public License version 3 as published by the Free Software and appearing
* in the file LICENSE.AGPL included in the packaging of this file.
* The BEAT platform is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
*
* You should have received a copy of the GNU Affero Public License along
* with the BEAT platform. If not, see http://www.gnu.org/licenses/.
{% endcomment %}
{% load ui_tags %}
<div class="row">
<div class="col-sm-10">
{% if object.splittable or object.analysis %}
<div class="alert alert-{% if object.splittable %}success{% else %}info{% endif %}">
{% if object.splittable %}<i class="fa fa-random"></i> This plotter is <strong><span class="text-success">splittable</span></strong>{% endif %}
{% if object.analysis %}<i class="fa fa-line-chart"></i> This plotter is an <strong><span class="text-primary">analyzer</span></strong>. It can only be used on analysis blocks.{% endif %}
</div>
{% endif %}
{% with parameters=object.json_parameters uses_and_groups=object.uses_and_groups %}
{% with uses_and_groups.0 as uses %}
{% with uses_and_groups.1 as groups %}
{% if parameters or uses or groups %}
<div class="panel-group" role="tablist" aria-multiselectable="true">
{% if groups %}
<div id="groups-panel" class="panel panel-default">
<div class="panel-heading" role="tab" id="heading-groups">
<h4 class="panel-title">
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#groups-panel" href="#collapse-groups" aria-expanded="false" aria-controls="collapse-groups">
Endpoint Groups
</a> <span id="counter" class="badge">{{ groups|length }}</span>
</h4>
</div>
<div id="collapse-groups" class="panel-collapse collapse" role="tabpanel" aria-labelledby="heading-groups">
<div class="panel-body">
<div class="col-sm-10">
<p class="help">Plotters have at least one
<strong>input</strong> and one <strong>output</strong>. All
plotter endpoints are organized in <strong>groups</strong>.
Groups are used by the platform to indicate which inputs and
outputs are synchronized together. The first group is
automatically synchronized with the channel defined by the
block in which the plotter is deployed.</p>
{% for group in groups %}
<div class="panel panel-default">
{% if group.name %}
<div class="panel-heading">
<h4 class="panel-title">Group: {{ group.name }}</h4>
</div>
{% else %}
<div class="panel-heading">
<h4 class="panel-title">Unnamed group</h4>
</div>
{% endif %}
<table class="table table-responsive table-condensed table-hover">
<thead>
<tr>
<th>Endpoint Name</th>
<th>Data Format</th>
<th>Nature</th>
</tr>
</thead>
<tbody>
{% for name, value in group.inputs.items %}
<tr>
<td>{{ name }}</td>
<td>{% with value.type|split_fullname as parts %}<a href="{% url "dataformats:view" parts.0 parts.1 parts.2 %}" target="_blank" title="Click to see dataformat in a new window">{{ value.type }}</a>{% endwith %}</td>
<td><span class="text-success"><i class="fa fa-arrow-right"></i> Input</span></td>
</tr>
{% endfor %}
{% for name, value in group.outputs.items %}
<tr class="active">
<td>{{ name }}</td>
<td>{% with value.type|split_fullname as parts %}<a href="{% url "dataformats:view" parts.0 parts.1 parts.2 %}" target="_blank" title="Click to see dataformat in a new window">{{ value.type }}</a>{% endwith %}</td>
<td><span class="text-primary"><i class="fa fa-arrow-left"></i> Output</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}{# groups #}
{% if object.result_dataformat %}
{% with object.json_result as result %}
<div id="results-panel" class="panel panel-default">
<div class="panel-heading" role="tab" id="heading-results">
<h4 class="panel-title">
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#results-panel" href="#collapse-results" aria-expanded="false" aria-controls="collapse-results">
Results
</a> <span id="counter" class="badge">{{ result|length }}</span>
</h4>
</div>
<div id="collapse-results" class="panel-collapse collapse" role="tabpanel" aria-labelledby="heading-results">
<div class="panel-body">
<div class="col-sm-10">
<p class="help">Analyzers may produce any number of
<strong>results</strong>. Once experiments using this analyzer
are done, you may display the results or filter experiments
using criteria based on them.</p>
<table class="table table-responsive table-condensed table-hover">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for name, value in result.items %}
<tr>
<td>{% if "+" in name %}{{ name|cut:"+" }} <i title="This result is displayed by default on search results" class="fa fa-eye"></i>{% else %}{{ name }}{% endif %}</td>
<td>{% if "/" in value %}{% with value|split_fullname as parts %}<a target="_blank" title="Click to view this dataformat in another window" href="{% url "dataformats:view" parts.0 parts.1 parts.2 %}">{{ value }}</a>{% endwith %}{% else %}{{ value }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endwith %}
{% endif %}{# results #}
{% if parameters %}
<div id="parameters-panel" class="panel panel-default">
<div class="panel-heading" role="tab" id="heading-parameters">
<h4 class="panel-title">
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#parameters-panel" href="#collapse-parameters" aria-expanded="false" aria-controls="collapse-parameters">
Parameters
</a> <span id="counter" class="badge">{{ parameters|length }}</span>
</h4>
</div>
<div id="collapse-parameters" class="panel-collapse collapse" role="tabpanel" aria-labelledby="heading-parameters">
<div class="panel-body">
<div class="col-sm-10">
<p class="help">Parameters allow users to change the
configuration of an plotter when scheduling an experiment</p>
<table class="table table-responsive table-condensed table-hover">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Type</th>
<th>Default</th>
</tr>
</thead>
<tbody>
{% for key, value in parameters.items %}
<tr>
<td>{{ key }}</td>
<td class="help">{{ value.description }}</td>
<td>{{ value.type }}</td>
<td>{{ value.default }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}{# parameters #}
{% if uses %}
<div id="uses-panel" class="panel panel-default">
<div class="panel-heading" role="tab" id="heading-uses">
<h4 class="panel-title">
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#uses-panel" href="#collapse-uses" aria-expanded="false" aria-controls="collapse-uses">
Libraries
</a> <span id="counter" class="badge">{{ uses|length }}</span>
</h4>
</div>
<div id="collapse-uses" class="panel-collapse collapse" role="tabpanel" aria-labelledby="heading-uses">
<div class="panel-body">
<div class="col-sm-10">
<p class="help">Plotters may use functions and classes
declared in libraries. Here you can see the libraries and
import names used by this library. You <strong>don't</strong>
need to import the library manually on your code, the platform
will do it for you. Just use the object as it has been imported
with the selected named. For example, if you choose to import a
library using the name <code>lib</code>, then access function
<code>f</code> within your code like <code>lib.f()</code>.
</p>
<table class="table table-responsive table-condensed table-hover">
<thead>
<tr>
<th>Library</th>
<th>Import as</th>
</tr>
</thead>
<tbody>
{% for key, value in uses.items %}
<tr>
<td>{% with value|split_fullname as parts %}<a target="_blank" title="Click to view this library in a new window" data-toggle="tooltip" data-placement="top" href="{% url "libraries:view" parts.0 parts.1 parts.2 %}">{{ value }}</a>{% endwith %}</td>
<td>{{ key }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}{# libraries "uses" #}
</div>
{% endif %}{# groups, uses or parameters #}
{% endwith %}{# groups, uses or parameters #}
{% endwith %}{# groups, uses or parameters #}
{% endwith %}{# groups, uses or parameters #}
{% if open_source %}
<textarea class="form-control" id="code-display">{{ object.source_code_file.read }}</textarea>
<p class="help-block">{{ texts.code|safe }}</p>
{% else %}
<div class="alert alert-warning">
<i class="fa fa-warning"></i> This plotter is only usable to you. Its code was <strong>not</strong> shared.
</div>
{% endif %}
</div>
{% if owner and object.modifiable %}
<div class="col-sm-2 action-buttons">
<a id="btn-edit-object" class="btn btn-primary btn-sm pull-right" href="{% url 'plotters:edit' object.author.username object.name object.version %}"><i class="fa fa-edit fa-lg"></i> Edit</a>
</div>
{% endif %}
</div>
{% if open_source %}
<script type="text/javascript">
$(document).ready(function() {
var code_textarea = $('textarea#code-display');
var code_editor = CodeMirror.fromTextArea(code_textarea[0], {
mode: '{{ object.get_language_display|lower }}',
readOnly: true,
});
code_editor.refresh();
});
</script>
{% endif %}
{% comment %}
* Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/
* Contact: beat.support@idiap.ch
*
* This file is part of the beat.web module of the BEAT platform.
*
* Commercial License Usage
* Licensees holding valid commercial BEAT licenses may use this file in
* accordance with the terms contained in a written agreement between you
* and Idiap. For further information contact tto@idiap.ch
*
* Alternatively, this file may be used under the terms of the GNU Affero
* Public License version 3 as published by the Free Software and appearing
* in the file LICENSE.AGPL included in the packaging of this file.
* The BEAT platform is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
*
* You should have received a copy of the GNU Affero Public License along
* with the BEAT platform. If not, see http://www.gnu.org/licenses/.
{% endcomment %}
{% load ui_tags %}
{% load plotter_tags %}
<div class="row filter">
<div class="col-sm-10 vertical-center">
{% if objects %}
<div id="filters" class="form-inline">
<div class="form-group form-group-sm">
<div class="input-group input-group-sm">
<span class="input-group-addon" id="basic-addon1"><i class="fa fa-search"></i></span>
<input type="text" tabindex="2" id="text-filter" class="form-control" placeholder="Filter rows..." aria-describedby="basic-addon1">
</div>
</div>
<div class="form-group form-group-sm">
<label for="privacy-filter" class="control-label">Privacy:</label>
<select id="privacy-filter" class="form-control">
<option selected>All</option>
<option>Public</option>
<option>Shared</option>
<option>Private</option>
</select>
</div>
</div>
{% endif %}
<!-- Notice there can be no div space if vertical-center is used -->
</div><div class="col-sm-2 vertical-center">
</div><!-- col -->
</div><!-- row -->
<div class="row">
<div class="col-sm-12">
{% if objects %}
<div class="scrollable table-responsive">
<table id="{{ panel_id }}" class="table table-hover table-condensed object-list plotter-list">
<thead>
<tr>
<th class="privacy"></th>
<th class="date">Updated</th>
<th>Name</th>
{% if request.user.is_staff %}
<th class="actions">Actions</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for obj in objects %}
<tr>
<td class="privacy">
<a title="{{ obj.get_sharing_display }}" data-toggle="tooltip" data-placement="top">
{% if obj.get_sharing_display == 'Private' %}
<i class="fa fa-user fa-2x"></i>
{% elif obj.get_sharing_display == 'Shared' %}
<i class="fa fa-users fa-2x"></i>
{% else %}
<i class="fa fa-globe fa-2x"></i>
{% endif %}
</a>
</td>
<td class="date">{{ obj.creation_date|date }}</td>
<td class="name"><a href="{{ obj.get_absolute_url }}" data-toggle="tooltip" data-placement="top" title="Click to view">{{ obj.fullname }}{% if obj.short_description %} <span class='help'>({{ obj.short_description }})</span>{% endif %}</a></td>
{% if request.user.is_staff %}
<td class="actions">
{% plotter_actions obj True %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div><!-- col -->
</div><!-- row -->
{% if not objects %}
<div class="row">
<div class="col-sm-12 not-found">
No plotter found
</div>
</div><!-- row -->
{% endif %} <!-- if not objects -->
{% filter_script panel_id "text-filter" "privacy-filter" %}
{% extends "base.html" %}
{% comment %}
* Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/
* Contact: beat.support@idiap.ch
*
* This file is part of the beat.web module of the BEAT platform.
*
* Commercial License Usage
* Licensees holding valid commercial BEAT licenses may use this file in
* accordance with the terms contained in a written agreement between you
* and Idiap. For further information contact tto@idiap.ch
*
* Alternatively, this file may be used under the terms of the GNU Affero
* Public License version 3 as published by the Free Software and appearing
* in the file LICENSE.AGPL included in the packaging of this file.
* The BEAT platform is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
*
* You should have received a copy of the GNU Affero Public License along
* with the BEAT platform. If not, see http://www.gnu.org/licenses/.
{% endcomment %}
{% load plotter_tags %}
{% load experiment_tags %}
{% load report_tags %}
{% load ui_tags %}
{% load fingerprint %}
{% block title %}{{ block.super }} - {{ plotter.fullname }}{% endblock %}
{% block stylesheets %}
{{ block.super }}
<link rel="stylesheet" href="{% fingerprint "experiments/css/style.css" %}" type="text/css" media="screen" />
<link rel="stylesheet" href="{% fingerprint "chosen-bootstrap/chosen.bootstrap.min.css" %}" type="text/css" media="screen" />
<link rel="stylesheet" href="{% fingerprint "ui/css/rst.css" %}" type="text/css" media="screen" />
{% code_editor_css %}
{% endblock %}
{% block scripts %}
{{ block.super }}
<script src="{% fingerprint "chosen/chosen.jquery.min.js" %}" type="text/javascript" charset="utf-8"></script>
<script src="{% fingerprint "experiments/js/utils.js" %}" type="text/javascript" charset="utf-8"></script>
<script src="{% fingerprint "ui/js/history.js" %}" type="text/javascript" charset="utf-8"></script>
<script src="{% fingerprint "raphael/raphael-min.js" %}" type="text/javascript" charset="utf-8"></script>
{% code_editor_scripts "python,rst" %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-9 vertical-center" onmouseover="expand_breadcrumb(this, 9, 3);" onmouseout="reset_breadcrumb(this, 9, 3);">
{% contribution_breadcrumb_plotter plotter %}
<!-- Note: keep no space between divs here! -->
</div><div class="col-sm-3 vertical-center">
{% plotter_actions plotter False %}
</div>
</div>
{% if plotter.short_description %}
<div class="row">
<div class="col-sm-12">
<p class="help-block"><i class="fa fa-file-text"></i> {{ plotter.short_description }}</p>
</div>
</div>
{% endif %}
{% if plotter.fork_of %}
<div class="row">
<div class="col-sm-12">
<p class="help-block"><i class="fa fa-code-fork"></i> Forked from <a href="{{ plotter.fork_of.get_absolute_url }}">{{ plotter.fork_of.fullname }}</a></p>
</div>
</div>
{% endif %}
{% visible_reports plotter as reports %}
<div class="row">
<div class="col-sm-12">
{# Navigation Tabs #}
<ul id="object-tabs" class="nav nav-tabs" role="tablist">
<li role="presentation" class="active"><a href="#viewer" role="tab" data-toggle="tab" aria-controls="viewer">Plotter</a></li>
<li role="presentation"><a {% if not plotter.description %}title="No documentation available" {% endif %}href="#doc" role="tab" data-toggle="tab" aria-controls="doc">Documentation{% if not plotter.description %} <i class="fa fa-warning"></i>{% endif %}</a></li>
{% if owner %}
<li role="presentation"><a href="#sharing" role="tab" data-toggle="tab" aria-controls="sharing">Sharing</a></li>
{% endif %}
<li role="presentation"><a href="#reports" role="tab" data-toggle="tab" aria-controls="reports">Reports<span class="badge">{{ reports.count }}</span></a></li>
<li role="presentation"><a href="#history" role="tab" data-toggle="tab" aria-controls="history">History</a></li>
</ul>
{# Navigation Panes #}
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="viewer">
{% plotter_editor plotter %}
</div>
<div role="tabpanel" class="tab-pane" id="doc">
{% doc_editor plotter 'api_plotters:object' %}
</div>
{% if owner %}
<div role="tabpanel" class="tab-pane" id="sharing">
{% plotter_sharing plotter %}
</div>
{% endif %}
<div role="tabpanel" class="tab-pane" id="reports">
{% if reports.count %}
<h4>Reports</h4>
{% report_table reports.all owner "report-list" %}
{% else %}
No reports are using this plotter.
{% endif %}
</div>
<div role="tabpanel" class="tab-pane" id="history">
{% history 'plotters' plotter 'history' 400 %}
</div>
<div role="tabpanel" class="tab-pane" id="compatibility">
<div class="col-sm-5">
{% with plotter.environments as execinfo %}
{% if not execinfo %}
This plotter was never executed.
{% else %}
<ul class="list-group">
{% for key, value in execinfo %}<li class="list-group-item"><a title="Click to view" data-toggle="tooltip" data-placement="top" href="{% url 'backend:view-environment' key.name key.version %}">{{ key.fullname }}</a> <span class="badge">{{ value }}</span></li>{% endfor %}
</ul>
<p class="help">This table shows the number of times this plotter
has been <b>successfuly</b> run using the given environment. Note
this does not provide sufficient information to evaluate if the
plotter will run when submitted to different conditions.</p>
{% endif %}
{% endwith %}
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
$(document).ready(function(){manage_tabs('ul#object-tabs');})
</script>
{% endblock %}
#!/usr/bin/env python
# vim: set fileencoding=utf-8 :
###############################################################################
# #
# Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/ #
# Contact: beat.support@idiap.ch #
# #
# This file is part of the beat.web module of the BEAT platform. #
# #
# Commercial License Usage #
# Licensees holding valid commercial BEAT licenses may use this file in #
# accordance with the terms contained in a written agreement between you #
# and Idiap. For further information contact tto@idiap.ch #
# #
# Alternatively, this file may be used under the terms of the GNU Affero #
# Public License version 3 as published by the Free Software and appearing #
# in the file LICENSE.AGPL included in the packaging of this file. #
# The BEAT platform is distributed in the hope that it will be useful, but #
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY #
# or FITNESS FOR A PARTICULAR PURPOSE. #
# #
# You should have received a copy of the GNU Affero Public License along #
# with the BEAT platform. If not, see http://www.gnu.org/licenses/. #
# #
###############################################################################
import random
from django import template
from django.conf import settings
from ..models import Plotter
register = template.Library()
@register.inclusion_tag('plotters/panels/table.html', takes_context=True)
def plotter_table(context, objects, owner, id):
'''Composes a plotter list table
This panel primarily exists for user's plotter list page.
Parameters:
objects (iterable): An iterable containing plotter objects
owner (bool): A flag indicating if the list is being created for the
owner of his personal list page on the user-microsite or not.
id: The HTML id to set on the generated table. This is handy for the
filter functionality normally available on list pages.
'''
return dict(
request=context['request'],
objects=objects,
owner=owner,
panel_id=id,
)
@register.inclusion_tag('plotters/panels/actions.html', takes_context=True)
def plotter_actions(context, object, display_count):
'''Composes the action buttons for a particular plotter
This panel primarily exists for showing action buttons for a given
plotter taking into consideration it is being displayed for a given user.
Parameters:
object (plotter): The plotter object concerned for which the
buttons will be drawn.
display_count (bool): If the set of buttons should include one with the
number of experiments using this plotter.
'''
return dict(
request=context['request'],
object=object,
display_count=display_count,
)
@register.inclusion_tag('plotters/panels/sharing.html', takes_context=True)
def plotter_sharing(context, obj):
'''Composes the current sharing properties and a form to change them
Parameters:
obj (plotter): The plotter object concerned for which the
sharing panel will be drawn
'''
return {
'request': context['request'],
'object': obj,
'owner': context['request'].user == obj.author,
'users': context['users'],
'teams': context['teams'],
}
@register.inclusion_tag('plotters/panels/viewer.html', takes_context=True)
def plotter_viewer(context, obj, xp, id):
'''Composes a canvas with the plotter (no further JS setup is
required)
Parameters:
obj (plotter): The plotter object concerned for which the
panel will be drawn.
xp (Experiment): The experiment to project on the top of the plotter
components. If not given, just draw the plotter.
id (str): The id of the canvas element that will be created
'''
return {
'request': context['request'],
'object': obj,
'xp': xp,
'panel_id': id,
'URL_PREFIX': settings.URL_PREFIX,
}
@register.assignment_tag(takes_context=True)
def random_plotter(context):
'''Returns a random plotter that is visible to the current user'''
candidates = Plotter.objects.for_user(context['request'].user, True)
return candidates[random.randint(0, candidates.count()-1)]
@register.assignment_tag(takes_context=True)
def visible_plotters(context):
'''Calculates the visible plotters for a given user'''
return Plotter.objects.for_user(context['request'].user, True)
@register.assignment_tag(takes_context=True)
def visible_reports(context, object):
'''Calculates the visible experiments for a given plotter and requestor'''
return object.reports.for_user(context['request'].user, True)
#----------------------------------------------------------------
@register.inclusion_tag('plotters/panels/editor.html', takes_context=True)
def plotter_editor(context, obj):
request = context['request']
return {
'owner': request.user == obj.author,
'object': obj,
'open_source': obj.open_source(request.user),
}
@register.inclusion_tag('plotters/dialogs/import_settings.html')
def plotter_import_settings(id):
return { 'dialog_id': id,
}
#!/usr/bin/env python
# vim: set fileencoding=utf-8 :
###############################################################################
# #
# Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/ #
# Contact: beat.support@idiap.ch #
# #
# This file is part of the beat.web module of the BEAT platform. #
# #
# Commercial License Usage #
# Licensees holding valid commercial BEAT licenses may use this file in #
# accordance with the terms contained in a written agreement between you #
# and Idiap. For further information contact tto@idiap.ch #
# #
# Alternatively, this file may be used under the terms of the GNU Affero #
# Public License version 3 as published by the Free Software and appearing #
# in the file LICENSE.AGPL included in the packaging of this file. #
# The BEAT platform is distributed in the hope that it will be useful, but #
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY #
# or FITNESS FOR A PARTICULAR PURPOSE. #
# #
# You should have received a copy of the GNU Affero Public License along #
# with the BEAT platform. If not, see http://www.gnu.org/licenses/. #
# #
###############################################################################
import os
import shutil
import simplejson as json
from django.test import TestCase
from django.contrib.auth.models import User
from django.conf import settings
from django.core.urlresolvers import reverse
from .models import Plotter, PlotterParameter
from ..common.models import Shareable
from ..common.testutils import BaseTestCase
from rest_framework import status
from rest_framework.test import APITestCase
#----------------------------------------------------------
class PlotterParameterTestCase(APITestCase):
def setUp(self):
self.tearDown()
# Create the users
self.password = '1234'
self.johndoe = User.objects.create_user('johndoe', 'johndoe@test.org', self.password)
self.jackdoe = User.objects.create_user('jackdoe', 'jackdoe@test.org', self.password)
self.plot = User.objects.create_user('plot', 'plotdoe@test.org', self.password)
def tearDown(self):
for path in [settings.TOOLCHAINS_ROOT, settings.EXPERIMENTS_ROOT,
settings.DATAFORMATS_ROOT, settings.ALGORITHMS_ROOT,
settings.CACHE_ROOT]:
if os.path.exists(path):
shutil.rmtree(path)
#----------------------------------------------------------
class PlotterParameterCreationTestCase(PlotterParameterTestCase):
def setUp(self):
super(PlotterParameterCreationTestCase, self).setUp()
self.url = reverse('api_plotters:view', kwargs={'author_name': self.johndoe.username})
self.data = {\
#'author':self.johndoe,\
'name':'plotterparameter1',\
'short_description':'some description',\
'description':'some longer description'\
}
def test_anonymous_user(self):
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, '{"detail":"Authentication credentials were not provided."}')
def test_logged_in_user(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data['name'])
def test_logged_in_user_existing_plotterparameter_name(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data['name'])
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(json.loads(response.content)['non_field_errors'][0], 'This plotterparameter name already exists on this account')
#----------------------------------------------------------
class PlotterParameterListTestCase(PlotterParameterTestCase):
def setUp(self):
super(PlotterParameterListTestCase, self).setUp()
self.url = reverse('api_plotters:view', kwargs={'author_name': self.johndoe.username})
self.data = {\
#'author':self.johndoe,\
'name':'plotterparameter1',\
'short_description':'some description',\
'description':'some longer description'\
}
self.data2 = {\
#'author':self.johndoe,\
'name':'plotterparameter2',\
'short_description':'some description2',\
'description':'some longer description2'\
}
def test_anonymous_user(self):
response = self.client.get(self.url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_logged_in_user_no_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.get(self.url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(json.loads(response.content)), 0)
def test_logged_in_user_single_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data['name'])
response = self.client.get(self.url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(json.loads(response.content)), 1)
def test_logged_in_user_multiple_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data['name'])
response = self.client.post(self.url, self.data2, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data2['name'])
response = self.client.get(self.url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(json.loads(response.content)), 2)
#----------------------------------------------------------
class PlotterParameterRetrievalTestCase(PlotterParameterTestCase):
def setUp(self):
super(PlotterParameterRetrievalTestCase, self).setUp()
self.url = reverse('api_plotters:view', kwargs={'author_name': self.johndoe.username})
self.url_single_plotterparameter = reverse('api_plotters:view', kwargs={
'author_name': self.johndoe.username,
'object_name': 'plotterparameter1',
'version': 1,
})
self.url_single_plotterparameter2 = reverse('api_plotters:view', kwargs={
'author_name': self.johndoe.username,
'object_name': 'plotterparameter2',
'version': 1,
})
self.data = {\
#'author':self.johndoe,\
'name':'plotterparameter1',\
'short_description':'some description',\
'description':'some longer description'\
}
self.data2 = {\
#'author':self.johndoe,\
'name':'plotterparameter2',\
'short_description':'some description2',\
'description':'some longer description2'\
}
def test_anonymous_user(self):
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_logged_in_user_no_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_logged_in_user_single_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data['name'])
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content)['name'], self.johndoe.username+'/'+self.data['name']+'/1')
def test_logged_in_user_multiple_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data['name'])
response = self.client.post(self.url, self.data2, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data2['name'])
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content)['name'], self.johndoe.username+'/'+self.data['name']+'/1')
response = self.client.get(self.url_single_plotterparameter2, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content)['name'], self.johndoe.username+'/'+self.data2['name']+'/1')
#----------------------------------------------------------
class PlotterParameterUpdateTestCase(PlotterParameterTestCase):
def setUp(self):
super(PlotterParameterUpdateTestCase, self).setUp()
self.url = reverse('api_plotters:view', kwargs={'author_name': self.johndoe.username})
self.url_single_plotterparameter = reverse('api_plotters:view', kwargs={
'author_name': self.johndoe.username,
'object_name': 'plotterparameter1',
'version': 1,
})
self.url_single_plotterparameter2 = reverse('api_plotters:view', kwargs={
'author_name': self.johndoe.username,
'object_name': 'plotterparameter2',
'version': 1,
})
self.data = {\
#'author':self.johndoe,\
'name':'plotterparameter1',\
'short_description':'some description',\
'description':'some longer description'\
}
self.data2 = {\
#'author':self.johndoe,\
'name':'plotterparameter2',\
'short_description':'some description2',\
'description':'some longer description2'\
}
def test_anonymous_user(self):
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_logged_in_user_no_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_logged_in_user_single_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data['name'])
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content)['name'], self.johndoe.username+'/'+self.data['name']+'/1')
def test_logged_in_user_multiple_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data['name'])
response = self.client.post(self.url, self.data2, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data2['name'])
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content)['name'], self.johndoe.username+'/'+self.data['name']+'/1')
response = self.client.get(self.url_single_plotterparameter2, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content)['name'], self.johndoe.username+'/'+self.data2['name']+'/1')
#----------------------------------------------------------
class PlotterParameterDeletionTestCase(PlotterParameterTestCase):
def setUp(self):
super(PlotterParameterDeletionTestCase, self).setUp()
self.url = reverse('api_plotters:view', kwargs={'author_name': self.johndoe.username})
self.url_single_plotterparameter = reverse('api_plotters:view', kwargs={
'author_name': self.johndoe.username,
'object_name': 'plotterparameter1',
'version': 1,
})
self.url_single_plotterparameter2 = reverse('api_plotters:view', kwargs={
'author_name': self.johndoe.username,
'object_name': 'plotterparameter2',
'version': 1,
})
self.data = {\
#'author':self.johndoe,\
'name':'plotterparameter1',\
'short_description':'some description',\
'description':'some longer description'\
}
self.data2 = {\
#'author':self.johndoe,\
'name':'plotterparameter2',\
'short_description':'some description2',\
'description':'some longer description2'\
}
def test_anonymous_user(self):
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_logged_in_user_no_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_logged_in_user_single_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data['name'])
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content)['name'], self.johndoe.username+'/'+self.data['name']+'/1')
def test_logged_in_user_multiple_plotterparameter(self):
self.client.login(username=self.johndoe.username, password=self.password)
response = self.client.post(self.url, self.data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data['name'])
response = self.client.post(self.url, self.data2, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(json.loads(response.content)['name'], self.data2['name'])
response = self.client.get(self.url_single_plotterparameter, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content)['name'], self.johndoe.username+'/'+self.data['name']+'/1')
response = self.client.get(self.url_single_plotterparameter2, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content)['name'], self.johndoe.username+'/'+self.data2['name']+'/1')
...@@ -42,4 +42,34 @@ urlpatterns = [ ...@@ -42,4 +42,34 @@ urlpatterns = [
name='plot', name='plot',
), ),
url(
r'^(?P<author>\w+)/(?P<name>[-\w]+)/(?P<version>\d+)/$',
views.view,
name='view',
),
url(
r'^(?P<author_name>\w+)/$',
views.list_plotters,
name='plotter-list',
),
url(
r'^$',
views.list_plotters_public,
name='plotter-public-list',
),
url(
r'^(?P<author>\w+)/(?P<name>[-\w]+)/$',
views.view,
name='plotter-view-latest',
),
url(
r'^(?P<author_name>\w+)/plotterparameter/$',
views.list_plotterparameters,
name='plotterparameter-list',
),
] ]
...@@ -37,13 +37,17 @@ import simplejson ...@@ -37,13 +37,17 @@ import simplejson
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseForbidden, HttpResponseBadRequest
from django.conf import settings from django.conf import settings
from django.shortcuts import render_to_response
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.template import RequestContext, Context
from django.contrib.auth.models import User
from ..experiments.models import Experiment, Block, Result from ..experiments.models import Experiment, Block, Result
from ..dataformats.models import DataFormat from ..dataformats.models import DataFormat
from ..reports.models import Report from ..reports.models import Report
from .models import Plotter, PlotterParameter, DefaultPlotter from .models import Plotter, PlotterParameter, DefaultPlotter
from ..team.models import Team
import beat.core.experiment import beat.core.experiment
import beat.core.toolchain import beat.core.toolchain
...@@ -293,12 +297,95 @@ def plot(request): ...@@ -293,12 +297,95 @@ def plot(request):
if do_b64_encode: fig = base64.b64encode(fig) if do_b64_encode: fig = base64.b64encode(fig)
return HttpResponse(fig, content_type=final_parameters['content_type']) return HttpResponse(fig, content_type=final_parameters['content_type'])
#----------------------------------------------------------
def view(request, author, name, version=None):
"""Shows the algorithm. The Web API is used to retrieve the details about
the algorithm and check the accessibility.
"""
# Retrieves the algorithm
if version:
plotter = get_object_or_404(
Plotter,
author__username__iexact=author,
name__iexact=name,
version=int(version),
)
else:
plotter = Plotter.objects.filter(author__username__iexact=author,
name__iexact=name).order_by('-version')
if not plotter:
raise Http404()
else:
plotter = plotter[0]
(has_access, _, __) = plotter.accessibility_for(request.user)
if not has_access: raise Http404()
owner = (request.user == plotter.author)
# Users the object can be shared with
users = User.objects.exclude(username__in=settings.ACCOUNTS_TO_EXCLUDE_FROM_TEAMS).order_by('username')
# Render the page
return render_to_response('plotters/view.html',
{
'plotter': plotter,
'owner': owner,
'users': users,
'teams': Team.objects.for_user(request.user, True)
},
context_instance=RequestContext(request))
#---------------------------------------------------------- #----------------------------------------------------------
def list_plotters(request, author_name): def list_plotters(request, author_name):
'''List all accessible plotters to the request user''' '''List all accessible plotters to the request user'''
# check that the user exists on the system
author = get_object_or_404(User, username=author_name)
objects=Plotter.objects.from_author_and_public(request.user,author_name)
owner=(request.user==author)
return render_to_response('plotters/list.html',
dict(
objects=objects,
author=author,
owner=owner,
),
context_instance=RequestContext(request),
)
#----------------------------------------------------------
def list_plotters_public(request):
'''List all accessible plotters to the request user'''
# orders so that objects with latest information are displayed first
objects=Plotter.objects.public().order_by('-creation_date')
return render_to_response('plotters/list.html',
dict(
objects=objects,
author=request.user, #anonymous
owner=False,
),
context_instance=RequestContext(request),
)
#----------------------------------------------------------
def list_plotterparameters(request, author_name):
'''List all accessible plotters to the request user'''
return render_to_response('plotters/list.html', return render_to_response('plotters/list.html',
dict(objects=Plotter.objects.from_author_and_public(request.user, dict(objects=Plotter.objects.from_author_and_public(request.user,
......
...@@ -30,6 +30,7 @@ ...@@ -30,6 +30,7 @@
from django import template from django import template
from django.conf import settings from django.conf import settings
from ..models import Report from ..models import Report
from django.db.models.functions import Coalesce
register = template.Library() register = template.Library()
...@@ -53,6 +54,7 @@ def report_table(context, objects, owner, id): ...@@ -53,6 +54,7 @@ def report_table(context, objects, owner, id):
filter functionality normally available on list pages. filter functionality normally available on list pages.
''' '''
objects = objects.annotate(updated=Coalesce('publication_date', 'creation_date',)).order_by('-updated')
return dict( return dict(
request=context['request'], request=context['request'],
......
...@@ -30,6 +30,7 @@ ...@@ -30,6 +30,7 @@
<a class="list-group-item" href="{% if request.user.is_anonymous %}{% url 'attestations:public-list' %}{% else %}{% url 'attestations:list' request.user.username %}{% endif %}" title="Show attestations" data-toggle="tooltip" data-placement="left">Attestations <span class="badge">{{ totals.attestations }}</span></a> <a class="list-group-item" href="{% if request.user.is_anonymous %}{% url 'attestations:public-list' %}{% else %}{% url 'attestations:list' request.user.username %}{% endif %}" title="Show attestations" data-toggle="tooltip" data-placement="left">Attestations <span class="badge">{{ totals.attestations }}</span></a>
<a class="list-group-item" href="{% if request.user.is_anonymous %}{% url 'search:public-list' %}{% else %}{% url 'search:list' request.user.username %}{% endif %}" title="Show searches" data-toggle="tooltip" data-placement="left">Searches <span class="badge">{{ totals.searches }}</span></a> <a class="list-group-item" href="{% if request.user.is_anonymous %}{% url 'search:public-list' %}{% else %}{% url 'search:list' request.user.username %}{% endif %}" title="Show searches" data-toggle="tooltip" data-placement="left">Searches <span class="badge">{{ totals.searches }}</span></a>
<a class="list-group-item" href="{% if request.user.is_anonymous %}{% url 'reports:public-list' %}{% else %}{% url 'reports:list' request.user.username %}{% endif %}" title="Show reports" data-toggle="tooltip" data-placement="left">Reports <span class="badge">{{ totals.reports }}</span></a> <a class="list-group-item" href="{% if request.user.is_anonymous %}{% url 'reports:public-list' %}{% else %}{% url 'reports:list' request.user.username %}{% endif %}" title="Show reports" data-toggle="tooltip" data-placement="left">Reports <span class="badge">{{ totals.reports }}</span></a>
<a class="list-group-item" href="{% if request.user.is_anonymous %}{% url 'plotters:plotter-public-list' %}{% else %}{% url 'plotters:plotter-list' request.user.username %}{% endif %}" title="Show plotters" data-toggle="tooltip" data-placement="left">Plotters <span class="badge">{{ totals.plotters}}</span></a>
<a class="list-group-item" href="{% url 'databases:list' %}" title="Show databases" data-toggle="tooltip" data-placement="left">Databases <span class="badge">{{ totals.databases }}</span></a> <a class="list-group-item" href="{% url 'databases:list' %}" title="Show databases" data-toggle="tooltip" data-placement="left">Databases <span class="badge">{{ totals.databases }}</span></a>
<div class="list-group-item">Users <span class="badge">{{ totals.users }}</span></div> <div class="list-group-item">Users <span class="badge">{{ totals.users }}</span></div>
</div> </div>
......
...@@ -53,6 +53,7 @@ def calculate_totals(): ...@@ -53,6 +53,7 @@ def calculate_totals():
from ..team.models import Team from ..team.models import Team
from ..attestations.models import Attestation from ..attestations.models import Attestation
from ..reports.models import Report from ..reports.models import Report
from ..plotters.models import Plotter
from ..search.models import Search from ..search.models import Search
# for calculating the total cpu time, we use the HourlyStatistics and # for calculating the total cpu time, we use the HourlyStatistics and
...@@ -102,6 +103,7 @@ def calculate_totals(): ...@@ -102,6 +103,7 @@ def calculate_totals():
attestations=Attestation.objects.count(), attestations=Attestation.objects.count(),
searches=Search.objects.count(), searches=Search.objects.count(),
reports=Report.objects.count(), reports=Report.objects.count(),
plotters=Plotter.objects.count(),
) )
......
...@@ -168,6 +168,7 @@ ...@@ -168,6 +168,7 @@
<a href="{% url 'teams:list' author.username %}" class="list-group-item">Teams <span class="badge">{{ statistics.teams.count }}</span></a> <a href="{% url 'teams:list' author.username %}" class="list-group-item">Teams <span class="badge">{{ statistics.teams.count }}</span></a>
<a href="{% url 'attestations:list' author.username %}" class="list-group-item">Attestations <span class="badge">{{ statistics.attestations.count }}</span></a> <a href="{% url 'attestations:list' author.username %}" class="list-group-item">Attestations <span class="badge">{{ statistics.attestations.count }}</span></a>
<a href="{% url 'reports:list' author.username %}" class="list-group-item">Reports <span class="badge">{{ statistics.reports.count }}</span></a> <a href="{% url 'reports:list' author.username %}" class="list-group-item">Reports <span class="badge">{{ statistics.reports.count }}</span></a>
<a href="{% url 'plotters:plotter-list' author.username %}" class="list-group-item">Plotters<span class="badge">{{ statistics.plotters.count }}</span></a>
<a href="{% url 'search:list' author.username %}" class="list-group-item">Searches <span class="badge">{{ statistics.searches.count }}</span></a> <a href="{% url 'search:list' author.username %}" class="list-group-item">Searches <span class="badge">{{ statistics.searches.count }}</span></a>
</ul> </ul>
</div> </div>
......
{% comment %}
* Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/
* Contact: beat.support@idiap.ch
*
* This file is part of the beat.web module of the BEAT platform.
*
* Commercial License Usage
* Licensees holding valid commercial BEAT licenses may use this file in
* accordance with the terms contained in a written agreement between you
* and Idiap. For further information contact tto@idiap.ch
*
* Alternatively, this file may be used under the terms of the GNU Affero
* Public License version 3 as published by the Free Software and appearing
* in the file LICENSE.AGPL included in the packaging of this file.
* The BEAT platform is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
*
* You should have received a copy of the GNU Affero Public License along
* with the BEAT platform. If not, see http://www.gnu.org/licenses/.
{% endcomment %}
<h3>
<ol class="breadcrumb">
<li>
{% with p=object.get_sharing_display %}
{% if p == 'Private' %}
<i data-toggle="tooltip" data-placement="bottom" title="{{ p }}" class="fa fa-user fa-lg"></i>
{% elif p == 'Public' %}
<i data-toggle="tooltip" data-placement="bottom" title="{{ p }}" class="fa fa-globe fa-lg"></i>
{% elif p == 'Usable' %}
<i data-toggle="tooltip" data-placement="bottom" title="Usable, but no readable" class="fa fa-cog fa-lg"></i>
{% else %}<!-- shared specifically -->
<i data-toggle="tooltip" data-placement="bottom" title="Shared with some" class="fa fa-users fa-lg"></i>
{% endif %}
{% endwith %}
{% if not request.user.is_anonymous %}
<a data-toggle="tooltip" data-placement="bottom" title="View all your {{ name_plural }}" href="{% url listurl request.user.username %}">{{ name_plural }}</a>
{% else %}
<a data-toggle="tooltip" data-placement="bottom" title="View all public {{ name_plural }}" href="{% url public_listurl %}">{{ name_plural }}</a>
{% endif %}
</li>
<li><a data-toggle="tooltip" data-placement="bottom" title="View all {{ object.author.username }}'s {{ name_plural }}" href="{% url listurl object.author.username %}">{{ object.author.username }}</a></li>
<li><a title="View the latest version" data-toggle="tooltip" data-placement="bottom" href="{% url viewurl object.author.username object.name %}">{{ object.name }}</a></li>
<li>{{ object.version }}</li>
</ol>
</h3>
...@@ -58,6 +58,9 @@ def navbar(context): ...@@ -58,6 +58,9 @@ def navbar(context):
(False, ''), (False, ''),
('databases', 'databases:list'), ('databases', 'databases:list'),
('environments', 'backend:list-environments'), ('environments', 'backend:list-environments'),
(False, ''),
('plotters', 'plotters:plotter-public-list'),
#('plotterparameters', 'plotters:plotterparameter-public-list'),
], ],
'user_urls': [ 'user_urls': [
('experiments', 'experiments:list', True), ('experiments', 'experiments:list', True),
...@@ -74,6 +77,9 @@ def navbar(context): ...@@ -74,6 +77,9 @@ def navbar(context):
(False, '', False), (False, '', False),
('databases', 'databases:list', False), ('databases', 'databases:list', False),
('environments', 'backend:list-environments', False), ('environments', 'backend:list-environments', False),
(False, '', False),
('plotters', 'plotters:plotter-list', True),
#('plotterparameters', 'plotters:plotterparameter-list', True),
], ],
} }
...@@ -97,6 +103,22 @@ def contribution_breadcrumb(context, obj): ...@@ -97,6 +103,22 @@ def contribution_breadcrumb(context, obj):
#-------------------------------------------------- #--------------------------------------------------
@register.inclusion_tag('ui/contribution_breadcrumb_plotter.html', takes_context=True)
def contribution_breadcrumb_plotter(context, obj):
name_plural = obj.get_verbose_name_plural()
return {
'request': context['request'],
'object': obj,
'name_plural': name_plural,
'listurl': name_plural + ':plotter-list',
'public_listurl': name_plural + ':plotter-public-list',
'viewurl': name_plural + ':plotter-view-latest',
}
#--------------------------------------------------
@register.inclusion_tag('ui/filter_script.html') @register.inclusion_tag('ui/filter_script.html')
def filter_script(panel_id, text_filter_id, privacy_filter_id): def filter_script(panel_id, text_filter_id, privacy_filter_id):
return dict( return dict(
...@@ -139,6 +161,7 @@ def list_tabs(context, user, tab): ...@@ -139,6 +161,7 @@ def list_tabs(context, user, tab):
('reports', 'reports:' + name), ('reports', 'reports:' + name),
('searches', 'search:' + name), ('searches', 'search:' + name),
('teams', 'teams:' + name), ('teams', 'teams:' + name),
('plotters', 'plotters:plotter-' + name),
]), ]),
system_tabs=OrderedDict([ system_tabs=OrderedDict([
('databases', 'databases:list'), ('databases', 'databases:list'),
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment