Commit b7a8557b authored by Samuel GAIST's avatar Samuel GAIST

[protocoltemplates] Implement Django application for protocol templates handling

parent c7ad3889
#!/usr/bin/env python
# vim: set fileencoding=utf-8 :
###############################################################################
# #
# Copyright (c) 2021 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 simplejson as json
from django import forms
from django.contrib import admin
from django.core.files.base import ContentFile
from ..common.models import Shareable
from ..common.texts import Messages
from ..ui.forms import CodeMirrorJSONFileField
from ..ui.forms import CodeMirrorRSTFileField
from ..ui.forms import NameField
from .models import ProtocolTemplate as ProtocolTemplateModel
from .models import validate_protocoltemplate
# ----------------------------------------------------------
class ProtocolTemplateModelForm(forms.ModelForm):
name = NameField(
widget=forms.TextInput(attrs=dict(size=80)), help_text=Messages["name"],
)
declaration_file = CodeMirrorJSONFileField(
label="Declaration", help_text=Messages["format"],
)
description_file = CodeMirrorRSTFileField(
label="Description",
required=False,
allow_empty_file=True,
help_text=Messages["description"],
)
class Meta:
model = ProtocolTemplateModel
exclude = []
widgets = {
"short_description": forms.TextInput(attrs=dict(size=100),),
"extend": forms.Select(attrs=dict(disabled=1),),
}
def clean_declaration_file(self):
"""Cleans-up the declaration_file data, make sure it is really new"""
new_declaration = self.cleaned_data["declaration_file"].read()
old_declaration = ""
if self.instance and self.instance.declaration_file.name is not None:
old_declaration = self.instance.declaration_string
if new_declaration == old_declaration:
self.changed_data.remove("declaration_file")
content_file = ContentFile(old_declaration)
content_file.name = self.instance.declaration_file.name
return content_file
# we validate the protocol template to present errors close to the field
# on the form.
try:
core_protocoltemplate = validate_protocoltemplate(
json.loads(new_declaration)
)
except SyntaxError as e:
raise forms.ValidationError(str(e))
if not core_protocoltemplate.valid:
raise forms.ValidationError(
[forms.ValidationError(error) for error in core_protocoltemplate.errors]
)
# if that works out, then we return the passed file
self.cleaned_data["declaration_file"].seek(0) # reset ContentFile readout
return self.cleaned_data["declaration_file"]
def clean(self):
"""Cleans-up the input data, make sure it overall validates"""
if "declaration_file" in self.data and isinstance(
self.data["declaration_file"], str
):
mutable_data = self.data.copy()
mutable_data["declaration_file"] = ContentFile(
self.data["declaration_file"], name="unsaved"
)
self.data = mutable_data
# ----------------------------------------------------------
class ProtocolTemplate(admin.ModelAdmin):
list_display = (
"id",
"name",
"version",
"short_description",
"creation_date",
"previous_version",
"fork_of",
"sharing",
)
search_fields = ["name", "short_description", "previous_version__name"]
list_display_links = ["id", "name"]
list_filter = ["sharing"]
readonly_fields = ["short_description"]
form = ProtocolTemplateModelForm
filter_horizontal = ["shared_with", "shared_with_team"]
def new_version(self, request, queryset):
"""Creates a new version of a specific protocol template"""
for old_pt in queryset:
protocol_template = ProtocolTemplateModel.objects.create(
name=old_pt.name,
short_description=old_pt.short_description,
description=old_pt.description,
declaration=old_pt.declaration,
version=old_pt.version + 1,
previous_version=old_pt,
)
protocol_template.sharing = Shareable.PRIVATE
protocol_template.save()
self.message_user(
request,
f"Created {queryset.count()} new version(s) of selected "
"protocol templates.",
)
actions = ["new_version"]
fieldsets = [
(None, dict(fields=("name",),),),
(
"Documentation",
dict(
classes=("collapse",), fields=("short_description", "description_file"),
),
),
(
"Versioning",
dict(
classes=("collapse",),
fields=("version", "previous_version", "fork_of"),
),
),
(
"Sharing",
dict(
classes=("collapse",),
fields=("sharing", "shared_with", "shared_with_team"),
),
),
("Source code", dict(fields=("declaration_file",),),),
]
admin.site.register(ProtocolTemplateModel, ProtocolTemplate)
#!/usr/bin/env python
# vim: set fileencoding=utf-8 :
###############################################################################
# #
# Copyright (c) 2021 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/. #
# #
###############################################################################
from ..common import permissions as beat_permissions
from ..common.api import CheckContributionNameView
from ..common.api import ListCreateContributionView
from ..common.api import RetrieveUpdateDestroyContributionView
from ..common.api import ShareView as CommonShareView
from .models import ProtocolTemplate
from .serializers import ProtocolTemplateCreationSerializer
from .serializers import ProtocolTemplateModSerializer
from .serializers import ProtocolTemplateSerializer
# ----------------------------------------------------------
class CheckNameView(CheckContributionNameView):
"""
This view sanitizes a protocol template name and
checks whether it is already used.
"""
model = ProtocolTemplate
# ----------------------------------------------------------
class ShareView(CommonShareView):
"""
This view allows to share a protocol template with
other users and/or teams. Admin only
"""
model = ProtocolTemplate
permission_classes = [beat_permissions.IsAdminOrReadOnly]
# ----------------------------------------------------------
class ListCreateView(ListCreateContributionView):
"""
Read/Write end point that list the protocol templates available
and allows the creation of new protocol templates only to admins
"""
model = ProtocolTemplate
permission_classes = [beat_permissions.IsAdminOrReadOnly]
serializer_class = ProtocolTemplateSerializer
writing_serializer_class = ProtocolTemplateCreationSerializer
namespace = "api_protocoltemplates"
# ----------------------------------------------------------
class RetrieveUpdateDestroyView(RetrieveUpdateDestroyContributionView):
"""
Read/Write/Delete endpoint for a given protocol template
"""
model = ProtocolTemplate
permission_classes = [
beat_permissions.IsAdminOrReadOnly,
beat_permissions.IsModifiableOrRead,
]
serializer_class = ProtocolTemplateSerializer
writing_serializer_class = ProtocolTemplateModSerializer
#!/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/. #
# #
###############################################################################
from django.urls import path
from django.urls import re_path
from . import api
app_name = "api_protocoltemplates"
urlpatterns = [
path("", api.ListCreateView.as_view(), name="all"),
path("check_name/", api.CheckNameView.as_view(), name="check_name"),
re_path(
r"^(?P<object_name>[a-zA-Z0-9_\-]+)/(?P<version>\d+)/share/$",
api.ShareView.as_view(),
name="share",
),
re_path(
r"^(?P<object_name>[a-zA-Z0-9_\-]+)/(?P<version>\d+)/$",
api.RetrieveUpdateDestroyView.as_view(),
name="object",
),
]
#!/usr/bin/env python
# vim: set fileencoding=utf-8 :
###############################################################################
# #
# Copyright (c) 2021 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/. #
# #
###############################################################################
from django.utils.translation import ugettext_lazy as _
from ..common.apps import CommonAppConfig
class ProtocolTemplatesConfig(CommonAppConfig):
name = "beat.web.protocoltemplates"
verbose_name = _("Protocol Templates")
# Generated by Django 3.1.3 on 2021-01-27 09:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db import models
import beat.web.common.models
import beat.web.protocoltemplates.models
class Migration(migrations.Migration):
initial = True
dependencies = [
("databases", "0008_auto_20201113_0954"),
("team", "0002_auto_20201113_0954"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ProtocolTemplate",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"sharing",
models.CharField(
choices=[
("P", "Private"),
("S", "Shared"),
("A", "Public"),
("U", "Usable"),
],
default="P",
max_length=1,
),
),
(
"name",
models.CharField(
help_text="The name for this object (space-like characters will be automatically replaced by dashes)",
max_length=200,
),
),
(
"version",
models.PositiveIntegerField(
default=1,
help_text="The version of this object (an integer starting from 1)",
),
),
(
"short_description",
models.CharField(
blank=True,
default="",
help_text="Describe the object succinctly (try to keep it under 80 characters)",
max_length=100,
),
),
(
"creation_date",
models.DateTimeField(
auto_now_add=True, verbose_name="Creation date"
),
),
(
"hash",
models.CharField(
editable=False,
help_text='Hashed value of the object contents (<a href="https://docs.python.org/2/library/hashlib.html">SHA256, hexadecimal digest</a>). This field is auto-generated and managed by the platform.',
max_length=64,
),
),
(
"declaration_file",
models.FileField(
blank=True,
db_column="declaration",
max_length=200,
null=True,
storage=beat.web.protocoltemplates.models.ProtocolTemplateStorage(),
upload_to=beat.web.common.models.get_contribution_declaration_filename,
),
),
(
"description_file",
models.FileField(
blank=True,
db_column="description",
max_length=200,
null=True,
storage=beat.web.protocoltemplates.models.ProtocolTemplateStorage(),
upload_to=beat.web.common.models.get_contribution_description_filename,
),
),
("databases", models.ManyToManyField(to="databases.Database")),
(
"fork_of",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="forks",
to="protocoltemplates.protocoltemplate",
),
),
(
"previous_version",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="next_versions",
to="protocoltemplates.protocoltemplate",
),
),
(
"shared_with",
models.ManyToManyField(
blank=True,
related_name="shared_protocoltemplates",
to=settings.AUTH_USER_MODEL,
),
),
(
"shared_with_team",
models.ManyToManyField(
blank=True,
related_name="shared_protocoltemplates",
to="team.Team",
),
),
],
options={
"verbose_name_plural": "protocoltemplates",
"ordering": ["name", "-version"],
"abstract": False,
"unique_together": {("name", "version")},
},
),
]
import os
import simplejson
from django.conf import settings
from django.db import models
from django.urls import reverse
import beat.core.hash
import beat.core.protocoltemplate
from ..common import storage
from ..common.models import Versionable
from ..common.models import VersionableManager
from ..common.models import get_contribution_declaration_filename
from ..common.models import get_contribution_description_filename
from ..common.models import get_declaration
from ..common.models import get_declaration_string
from ..common.models import get_description
from ..common.models import set_declaration
from ..common.models import set_description
from ..common.storage import OverwriteStorage
from ..common.utils import annotate_full_name
from ..databases.models import Database
from ..dataformats.models import DataFormat
def validate_protocoltemplate(declaration):
"""Validates the declaration of a protocol template JSON string"""
protocol_template = beat.core.protocoltemplate.ProtocolTemplate(
settings.PREFIX, declaration
)
if not protocol_template.valid:
errors = (
"The protocol template declaration is **invalid**. Errors:\n * "
+ "\n * ".join(protocol_template.errors)
)
raise SyntaxError(errors)
return protocol_template
class ProtocolTemplateStorage(OverwriteStorage):
def __init__(self, *args, **kwargs):
super().__init__(*args, location=settings.PROTOCOLTEMPLATES_ROOT, **kwargs)
class ProtocolTemplateManager(VersionableManager):
def get_by_natural_key(self, name, version):
return self.get(name=name, version=version)
def create_protocoltemplate(
self,
name,
short_description="",
description="",
declaration=None,
version=1,
previous_version=None,
**kwargs,
):
"""Convenience function to create a new protocol template from its parts"""
# Create the protocol template representation
protocol_template = self.model(
name=self.model.sanitize_name(name),
version=version,
sharing=self.model.PRIVATE,
previous_version=previous_version,
)
# Makes sure we get a declaration in string format
if declaration is None:
if previous_version is not None:
declaration = previous_version.declaration
else:
default = beat.core.protocoltemplate.ProtocolTemplate(
settings.PREFIX, data=None
)
declaration = default.data
elif not (isinstance(declaration, dict)):
declaration = simplejson.loads(declaration)
if len(short_description) > 0:
declaration["description"] = short_description
protocol_template.declaration = declaration
# Check the provided description
if not description:
if previous_version is not None:
description = previous_version.description
else:
description = ""
protocol_template.description = description
# Save the protocol template (will run the validation)
try:
protocol_template.save()
except Exception:
import traceback
return (None, traceback.format_exc())
return (protocol_template, None)
# ----------------------------------------------------------
class ProtocolTemplate(Versionable):
# _____ Fields __________
declaration_file = models.FileField(
storage=ProtocolTemplateStorage(),
upload_to=get_contribution_declaration_filename,
blank=True,
null=True,
max_length=200,
db_column="declaration",
)
description_file = models.FileField(
storage=ProtocolTemplateStorage(),
upload_to=get_contribution_description_filename,
blank=True,
null=True,
max_length=200,
db_column="description",
)
databases = models.ManyToManyField(Database)
objects = ProtocolTemplateManager()
# _____ Meta parameters __________
class Meta(Versionable.Meta):
unique_together = ("name", "version")
verbose_name_plural = "protocoltemplates"
# _____ Utilities __________
def get_absolute_url(self):
return reverse("protocoltemplates:view", args=(self.name, self.version))
def natural_key(self):
return (self.name, self.version)
def save(self, *args, **kwargs):
# load protocol template descriptor declaration
declaration = self.declaration
# tries a simple validation
wrapper = validate_protocoltemplate(declaration)
# reset the description
self.short_description = (
wrapper.description
if (wrapper is not None) and (wrapper.description is not None)
else ""
)
# Save the changed files (if necessary)
storage.save_files(self)
super().save(*args, **kwargs)
# if the filename has changed, move the declaration
if self.declaration_filename() != self.declaration_file.name:
storage.rename_file(self, "declaration_file", self.declaration_filename())
storage.rename_file(self, "description_file", self.description_filename())
# _____ Methods __________
def modifiable(self):
"""Can modify if nobody points at me"""