From eb74ce4a50b2734296a62baf7975d2c3ab12441c Mon Sep 17 00:00:00 2001 From: Mad Price Ball Date: Mon, 15 Apr 2019 12:49:06 -0700 Subject: [PATCH] DataType feature (part 1) (#1026) Partly implements #981: * add models and views for viewing, creating, and updating DataTypes * add views for managing a project's registered DataTypes * handle specified DataTypes in the API for file uploads * some default behavior if DataTypes weren't specified during upload * records DataType edit history * adds public API endpoint for DataTypes * adds public API endpoint for projects Aspects related to sharing permissions are not part of this commit (i.e. requesting and authorizing data sharing according to DataType). Also, the feature at this stage does not enforce DataType in uploaded data, but does introduce a default we plan to use for legacy support. --- data_import/admin.py | 1 + data_import/forms.py | 60 +++++++ data_import/migrations/0019_datatype.py | 69 ++++++++ data_import/models.py | 147 ++++++++++++++++++ data_import/serializers.py | 28 +++- .../data_import/datatypes-create.html | 69 ++++++++ .../data_import/datatypes-detail.html | 68 ++++++++ .../templates/data_import/datatypes-list.html | 19 +++ .../data_import/datatypes-update.html | 105 +++++++++++++ data_import/urls.py | 19 +++ data_import/views.py | 95 ++++++++++- open_humans/api_urls.py | 10 ++ open_humans/api_views.py | 50 +++++- open_humans/fixtures/test-data.json | 60 +++++++ .../templates/pages/public-data-api.html | 63 ++++++-- open_humans/templatetags/utilities.py | 8 + open_humans/tests.py | 2 + private_sharing/api_views.py | 37 +++-- private_sharing/fixtures/test-data.json | 60 +++++++ private_sharing/forms.py | 89 +++++++++++ .../migrations/0020_auto_20190222_0036.py | 10 +- .../migrations/0021_auto_20190412_1908.py | 29 ++++ private_sharing/models.py | 6 +- private_sharing/serializers.py | 2 +- .../private_sharing/project-detail.html | 9 ++ .../private_sharing/select-datatypes.html | 49 ++++++ private_sharing/testing.py | 92 +++++++++++ private_sharing/tests.py | 10 ++ private_sharing/urls.py | 5 + private_sharing/views.py | 55 ++++++- 30 files changed, 1283 insertions(+), 43 deletions(-) create mode 100644 data_import/forms.py create mode 100644 data_import/migrations/0019_datatype.py create mode 100644 data_import/templates/data_import/datatypes-create.html create mode 100644 data_import/templates/data_import/datatypes-detail.html create mode 100644 data_import/templates/data_import/datatypes-list.html create mode 100644 data_import/templates/data_import/datatypes-update.html create mode 100644 private_sharing/migrations/0021_auto_20190412_1908.py create mode 100644 private_sharing/templates/private_sharing/select-datatypes.html diff --git a/data_import/admin.py b/data_import/admin.py index 15fd94015..064ed5afe 100644 --- a/data_import/admin.py +++ b/data_import/admin.py @@ -4,3 +4,4 @@ admin.site.register(models.DataFile) admin.site.register(models.NewDataFileAccessLog) +admin.site.register(models.DataType) diff --git a/data_import/forms.py b/data_import/forms.py new file mode 100644 index 000000000..3a152c04a --- /dev/null +++ b/data_import/forms.py @@ -0,0 +1,60 @@ +from django import forms + +from .models import DataType + + +class DataTypeForm(forms.ModelForm): + """ + A form for creating and editing DataTypes. + """ + + class Meta: # noqa: D101 + model = DataType + fields = ["name", "parent", "description"] + + def __init__(self, *args, **kwargs): + self.editor = kwargs.pop("editor") + return super().__init__(*args, **kwargs) + + def clean_parent(self): + """ + Verify that the parent is not the object itself nor a descendent. + """ + parent = self.cleaned_data.get("parent") + if not parent: + return parent + if self.instance.id == parent.id: + raise forms.ValidationError( + "A DataType cannot be assigned to be its own parent." + ) + elif self.instance in parent.all_parents: + raise forms.ValidationError( + "{0} is not an allowed parent, as it is a descendent of {1}.".format( + parent.name, self.instance.name + ) + ) + return parent + + def clean_name(self): + """ + Verify that the name is case insensitive unique. + """ + name = self.cleaned_data.get("name") + try: + dt = DataType.objects.get(name__iexact=name) + except DataType.DoesNotExist: + dt = self.instance + if not dt == self.instance: + raise forms.ValidationError( + "Please provide a unique name for this datatype" + ) + return name + + def clean(self, *args, **kwargs): + if self.instance: + if not self.instance.editable: + raise forms.ValidationError( + "Not editable: in use by one or more approved projects." + ) + self.instance.editor = self.editor + return super().clean(*args, **kwargs) diff --git a/data_import/migrations/0019_datatype.py b/data_import/migrations/0019_datatype.py new file mode 100644 index 000000000..ec77a4fb2 --- /dev/null +++ b/data_import/migrations/0019_datatype.py @@ -0,0 +1,69 @@ +# Generated by Django 2.2 on 2019-04-12 19:08 + +import django.contrib.postgres.fields.jsonb +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("open_humans", "0014_member_password_reset_redirect"), + ("data_import", "0018_auto_20190402_1947"), + ] + + operations = [ + migrations.CreateModel( + name="DataType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + max_length=128, + unique=True, + validators=[ + django.core.validators.RegexValidator( + "^[\\w\\-\\s]+$", + "Only alphanumeric characters, space, dash, and underscore are allowed.", + ) + ], + ), + ), + ("description", models.CharField(max_length=512)), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ( + "history", + django.contrib.postgres.fields.jsonb.JSONField(default=dict), + ), + ( + "last_editor", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="open_humans.Member", + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="children", + to="data_import.DataType", + ), + ), + ], + ) + ] diff --git a/data_import/models.py b/data_import/models.py index db2a043ca..f2c81ef3e 100644 --- a/data_import/models.py +++ b/data_import/models.py @@ -1,25 +1,35 @@ +from collections import OrderedDict import datetime import logging import os import uuid +import arrow from botocore.exceptions import ClientError from django.conf import settings from django.contrib.postgres.fields import JSONField +from django.core.validators import RegexValidator from django.urls import reverse from django.db import models from django.db.models import F +from django.utils import timezone from ipware.ip import get_ip from common import fields from common.utils import full_url +from open_humans.models import Member from .utils import get_upload_path logger = logging.getLogger(__name__) +charvalidator = RegexValidator( + r"^[\w\-\s]+$", + "Only alphanumeric characters, space, dash, and underscore are allowed.", +) + def is_public(member, source): """ @@ -267,3 +277,140 @@ class TestUserData(models.Model): related_name="test_user_data", on_delete=models.CASCADE, ) + + +class DataType(models.Model): + """ + Describes the types of data a DataFile can contain. + """ + + name = models.CharField( + max_length=128, blank=False, unique=True, validators=[charvalidator] + ) + parent = models.ForeignKey( + "self", blank=True, null=True, related_name="children", on_delete=models.PROTECT + ) + last_editor = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True) + description = models.CharField(max_length=512, blank=False) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + history = JSONField(default=dict) + + def __str__(self): + parents = self.all_parents + if parents: + parents.reverse() + parents = [parent.name for parent in parents if parent] + parents = ":".join(parents) + return str("{0}:{1}").format(parents, self.name) + return self.name + + def save(self, *args, **kwargs): + """ + Override save to record edit history and require an associated "editor". + + "editor" is an instance-specific parameter; this avoids accepting an update + that is merely retaining the existing value for the "last_editor" field. + """ + if not self.editor: + raise ValueError("'self.editor' must be set when saving DataType.") + else: + self.last_editor = self.editor + self.history[arrow.get(timezone.now()).isoformat()] = { + "name": self.name, + "parent": self.parent.id if self.parent else None, + "description": self.description, + "editor": self.last_editor.id, + } + return super().save(*args, **kwargs) + + @property + def history_sorted(self): + history_sorted = OrderedDict() + items_sorted = sorted( + self.history.items(), key=lambda item: arrow.get(item[0]), reverse=True + ) + for item in items_sorted: + parent = ( + DataType.objects.get(id=item[1]["parent"]) + if item[1]["parent"] + else None + ) + try: + editor = Member.objects.get(id=item[1]["editor"]) + except Member.DoesNotExist: + editor = None + history_sorted[arrow.get(item[0]).datetime] = { + "name": item[1]["name"], + "parent": parent, + "description": item[1]["description"], + "editor": editor, + } + return history_sorted + + @property + def editable(self): + """ + Return True if no approved projects are registered as using this. + """ + # Always true for a new instance that hasn't yet been saved: + if not self.id: + return True + + approved_registered = self.datarequestproject_set.filter(approved=True) + if approved_registered: + return False + else: + return True + + @property + def all_parents(self): + """ + Return list of parents, from immediate to most ancestral. + """ + parent = self.parent + parents = [] + if parent: + while True: + if not parent: + break + parents.append(parent) + parent = parent.parent + + return parents + + @classmethod + def all_as_tree(cls): + """ + Dict tree of all datatypes. Key = parent & value = array of child dicts. + + This method is intended to make all ancestry relationships available without + having to hit the database more than necessary. + """ + + def _children(parent, all_datatypes): + children = {} + for dt in [dt for dt in all_datatypes if dt.parent == parent]: + children[dt] = _children(dt, all_datatypes) + return children + + all_datatypes = list(DataType.objects.all()) + roots = DataType.objects.filter(parent=None) + tree = {dt: _children(dt, all_datatypes) for dt in roots} + return tree + + @classmethod + def sorted_by_ancestors(cls, queryset=None): + """ + Sort DataTypes by ancestors array of dicts containing 'datatype' and 'depth'. + """ + + def _flatten(node, depth=0): + flattened = [] + for child in sorted(node.keys(), key=lambda obj: obj.name): + flattened.append({"datatype": child, "depth": depth}) + flattened = flattened + _flatten(node[child], depth=depth + 1) + return flattened + + datatypes_tree = cls.all_as_tree() + return _flatten(datatypes_tree) diff --git a/data_import/serializers.py b/data_import/serializers.py index f3767371b..3683ac555 100644 --- a/data_import/serializers.py +++ b/data_import/serializers.py @@ -3,7 +3,9 @@ from rest_framework import serializers -from .models import AWSDataFileAccessLog, DataFile, NewDataFileAccessLog +from private_sharing.models import DataRequestProject + +from .models import AWSDataFileAccessLog, DataFile, DataType, NewDataFileAccessLog def serialize_datafile_to_dict(datafile): @@ -87,3 +89,27 @@ class Meta: # noqa: D101 "host_header", "datafile", ] + + +class DataTypeSerializer(serializers.ModelSerializer): + """ + Serialize DataTypes + """ + + class Meta: # noqa: D101 + model = DataType + + fields = ["id", "name", "parent", "description", "source_projects"] + + source_projects = serializers.SerializerMethodField() + + def get_source_projects(self, obj): + """ + Get approved projects that are registered as potential sources. + """ + projects = ( + DataRequestProject.objects.filter(approved=True) + .filter(registered_datatypes=obj) + .distinct() + ) + return [project.id_label for project in projects] diff --git a/data_import/templates/data_import/datatypes-create.html b/data_import/templates/data_import/datatypes-create.html new file mode 100644 index 000000000..eb1f442f3 --- /dev/null +++ b/data_import/templates/data_import/datatypes-create.html @@ -0,0 +1,69 @@ +{% extends 'base.html' %} + +{% load bootstrap_tags %} +{% load utilities %} + +{% block main %} +

Add a DataType

+

+ Use this form to add a new DataType. Please avoid redundant DataTypes and + assign your DataType to be a subcategory of another DataType when appropriate. +

+
+
+ {% csrf_token %} + + {% if form.errors %} +
+

Form errors

+
    + {% for error in form.errors %} +
  • {{ error }}: {{ form.errors|lookup:error }}
  • + {% endfor %} +
+

+
+ {% endif %} + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + +
+
+{% endblock %} diff --git a/data_import/templates/data_import/datatypes-detail.html b/data_import/templates/data_import/datatypes-detail.html new file mode 100644 index 000000000..5658346c0 --- /dev/null +++ b/data_import/templates/data_import/datatypes-detail.html @@ -0,0 +1,68 @@ +{% extends 'base.html' %} + +{% load bootstrap_tags %} + +{% block main %} +

DataType: {{ object.name }}

+ +

Name: {{ object.name }}

+

Description: {{ object.description }}

+

Ancestors: {% if object.parent %} + {% for parent in object.all_parents reversed %} + {{ parent.name }} > + {% endfor %} + {{ object.name }} + {% else %} + None + {% endif %} +

+

Children: {% if not object.children.all %}None{% endif %}

+{% if object.children.all %} + +{% endif %} +

Project sources: {% if not object.datarequestproject_set.all %}None{% endif %}

+{% if object.datarequestproject_set.all %} + +{% endif %} +
+{% if object.editable %} +

+ Edit this entry +

+{% endif %} +

Return to full list of DataTypes.

+{% if object.history_sorted %} +

History

+ +{% endif %} +{% endblock %} diff --git a/data_import/templates/data_import/datatypes-list.html b/data_import/templates/data_import/datatypes-list.html new file mode 100644 index 000000000..750b4416e --- /dev/null +++ b/data_import/templates/data_import/datatypes-list.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block main %} +

DataTypes

+ + + +
+Add datatype + + +{% endblock main %} \ No newline at end of file diff --git a/data_import/templates/data_import/datatypes-update.html b/data_import/templates/data_import/datatypes-update.html new file mode 100644 index 000000000..9f978423c --- /dev/null +++ b/data_import/templates/data_import/datatypes-update.html @@ -0,0 +1,105 @@ +{% extends 'base.html' %} + +{% load bootstrap_tags %} +{% load utilities %} + +{% block main %} +

Update DataType: {{ object.name }}

+

+ Use this form to edit the following DataType: +

+ + + + + + + + + + + + + + + + + +
Name:{{ object.name }}
Description:{{ object.description }}
Parent:{% if object.parent %} + + {{ object.parent.name }}{% else %} + None + {% endif %}
Children: + {% if object.children.all %} + {% for child in object.children.all %} + + {{ child.name }}{% if not forloop.last %},{% endif %} + {% endfor %} + {% else %}None{% endif %} +
+
+
+ {% csrf_token %} + + {% if form.errors %} +
+

Form errors

+
    + {% for error in form.errors %} +
  • {{ error }}: {{ form.errors|lookup:error }}
  • + {% endfor %} +
+

+
+ {% endif %} + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + +
+
+{% endblock %} diff --git a/data_import/urls.py b/data_import/urls.py index 18c0ad6b1..6ff014a71 100644 --- a/data_import/urls.py +++ b/data_import/urls.py @@ -3,6 +3,10 @@ from .views import ( AWSDataFileAccessLogView, DataFileDownloadView, + DataTypesCreateView, + DataTypesDetailView, + DataTypesListView, + DataTypesUpdateView, NewDataFileAccessLogView, ) @@ -14,6 +18,21 @@ DataFileDownloadView.as_view(), name="datafile-download", ), + # DataType endpoints + re_path( + r"^datatypes/create/", DataTypesCreateView.as_view(), name="datatypes-create" + ), + re_path( + r"^datatypes/update/(?P[\w-]+)$", + DataTypesUpdateView.as_view(), + name="datatypes-update", + ), + re_path( + r"^datatypes/(?P[\w-]+)$", + DataTypesDetailView.as_view(), + name="datatypes-detail", + ), + re_path(r"^datatypes/", DataTypesListView.as_view(), name="datatypes-list"), # Custom API endpoints for OHLOG_PROJECT_ID path( "awsdatafileaccesslog/", diff --git a/data_import/views.py b/data_import/views.py index b50619c92..207e47611 100644 --- a/data_import/views.py +++ b/data_import/views.py @@ -2,14 +2,24 @@ from django.contrib.auth import get_user_model from django.http import HttpResponseForbidden, HttpResponseRedirect -from django.views.generic import View +from django.urls import reverse +from django.views.generic import CreateView, DetailView, TemplateView, UpdateView, View from django_filters.rest_framework import DjangoFilterBackend from ipware.ip import get_ip from rest_framework.generics import ListAPIView +from common.mixins import NeverCacheMixin, PrivateMixin + from .filters import AccessLogFilter -from .models import AWSDataFileAccessLog, DataFile, DataFileKey, NewDataFileAccessLog +from .forms import DataTypeForm +from .models import ( + AWSDataFileAccessLog, + DataFile, + DataFileKey, + DataType, + NewDataFileAccessLog, +) from .permissions import LogAPIAccessAllowed from common.mixins import NeverCacheMixin from data_import.serializers import ( @@ -122,3 +132,84 @@ def get_queryset(self): serialized_data_file__user_id=self.request.user.id ) return queryset + + +class DataTypesSortedMixin(object): + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + datatypes_sorted = DataType.sorted_by_ancestors() + try: + max_depth = max([i["depth"] for i in datatypes_sorted]) + except ValueError: + max_depth = 0 + context.update({"datatypes_sorted": datatypes_sorted, "max_depth": max_depth}) + return context + + +class DataTypesListView(NeverCacheMixin, TemplateView): + """ + List all DataTypes. + """ + + template_name = "data_import/datatypes-list.html" + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + datatypes_sorted = DataType.sorted_by_ancestors() + try: + max_depth = max([i["depth"] for i in datatypes_sorted]) + except ValueError: + max_depth = 0 + context.update({"datatypes_sorted": datatypes_sorted, "max_depth": max_depth}) + return context + + +class DataTypesDetailView(NeverCacheMixin, DetailView): + """ + Information about a DataType. + """ + + model = DataType + template_name = "data_import/datatypes-detail.html" + + +class FormEditorMixin(object): + """ + Override get_form_kwargs to pass request user as 'editor' kwarg to a form. + """ + + def get_form_kwargs(self, *args, **kwargs): + kwargs = super().get_form_kwargs(*args, **kwargs) + kwargs["editor"] = self.request.user.member + return kwargs + + +class DataTypesCreateView( + PrivateMixin, DataTypesSortedMixin, FormEditorMixin, CreateView +): + """ + Create a new DataType. + """ + + form_class = DataTypeForm + template_name = "data_import/datatypes-create.html" + + def get_success_url(self): + return reverse("data-management:datatypes-list") + + +class DataTypesUpdateView( + PrivateMixin, DataTypesSortedMixin, FormEditorMixin, UpdateView +): + """ + Edit a DataType. + """ + + model = DataType + form_class = DataTypeForm + template_name = "data_import/datatypes-update.html" + + def get_success_url(self): + return reverse( + "data-management:datatypes-detail", kwargs={"pk": self.object.id} + ) diff --git a/open_humans/api_urls.py b/open_humans/api_urls.py index 609dae7ee..9aaf51f87 100644 --- a/open_humans/api_urls.py +++ b/open_humans/api_urls.py @@ -18,6 +18,16 @@ api_views.PublicDataUsersBySourceAPIView.as_view(), name="members-by-source", ), + path( + "public-data/datatypes/", + api_views.PublicDataTypesListAPIView.as_view(), + name="datatypes", + ), + path( + "public-data/projects/", + api_views.PublicProjectsListAPIView.as_view(), + name="projects", + ), ] diff --git a/open_humans/api_views.py b/open_humans/api_views.py index dc20d7ecb..684319399 100644 --- a/open_humans/api_views.py +++ b/open_humans/api_views.py @@ -4,11 +4,13 @@ from rest_framework.filters import SearchFilter from rest_framework.generics import ListAPIView -from data_import.models import DataFile -from private_sharing.models import DataRequestProject +from common.mixins import NeverCacheMixin +from data_import.models import DataFile, DataType +from data_import.serializers import DataTypeSerializer +from private_sharing.models import id_label_to_project, DataRequestProject +from private_sharing.serializers import ProjectDataSerializer from public_data.serializers import PublicDataFileSerializer -from common.mixins import NeverCacheMixin from .filters import PublicDataFileFilter from .serializers import ( DataUsersBySourceSerializer, @@ -89,3 +91,45 @@ class PublicDataUsersBySourceAPIView(NeverCacheMixin, ListAPIView): active=True, approved=True, no_public_data=False ) serializer_class = DataUsersBySourceSerializer + + +class PublicDataTypesListAPIView(ListAPIView): + """ + Return list of DataTypes and source projects that have registered them. + """ + + serializer_class = DataTypeSerializer + + def get_queryset(self): + """ + Get the queryset and filter on project if provided. + """ + source_project_label = self.request.GET.get("source_project_label", None) + if source_project_label: + source_project = id_label_to_project(source_project_label) + queryset = source_project.registered_datatypes.all() + else: + queryset = DataType.objects.all() + return queryset + + +class PublicProjectsListAPIView(ListAPIView): + """ + Return list of DataTypes and source projects that have registered them. + """ + + serializer_class = ProjectDataSerializer + + def get_queryset(self): + """ + Get the queryset and filter on project if provided. + """ + qs = DataRequestProject.objects.filter(approved=True) + project_label = self.request.GET.get("id_label", None) + if project_label: + project = id_label_to_project(project_label) + qs = qs.filter(id=project.id) + proj_id = self.request.GET.get("id", None) + if proj_id: + qs = qs.filter(id=proj_id) + return qs diff --git a/open_humans/fixtures/test-data.json b/open_humans/fixtures/test-data.json index e96da8223..4dac3bac2 100644 --- a/open_humans/fixtures/test-data.json +++ b/open_humans/fixtures/test-data.json @@ -759,5 +759,65 @@ "updated": "2017-06-04T04:42:21.812Z", "access_token": 772 } + }, +{ + "model": "data_import.datatype", + "pk": 1, + "fields": { + "name": "survey", + "description": "survey", + "created": "2016-02-24T22:11:38.721Z", + "modified": "2016-02-24T22:11:38.721Z", + "parent_id": null + } +}, +{ + "model": "data_import.datatype", + "pk": 2, + "fields": { + "name": "all your base", + "description": "are belong to us", + "created": "2016-03-24T22:11:38.721Z", + "modified": "2016-03-24T22:11:38.721Z", + "parent_id": 1 + } +}, +{ + "model": "data_import.datatype", + "pk": 3, + "fields": { + "name": "genetics", + "description": "genetics", + "created": "2016-02-24T22:11:38.721Z", + "modified": "2016-02-24T22:11:38.721Z", + "parent_id": null + } +}, +{ + "model": "data_import.datatype", + "pk": 4, + "fields": { + "name": "fourth one", + "description": "because I decided to have four", + "created": "2016-02-24T22:11:38.721Z", + "modified": "2016-02-24T22:11:38.721Z", + "parent_id": null + } +}, +{ + "model": "private_sharing.datarequestproject_registered_datatypes", + "pk": 1, + "fields": { + "datarequestproject_id": 2, + "datatype_id": 1 + } +}, +{ + "model": "private_sharing.datarequestproject_registered_datatypes", + "pk": 2, + "fields": { + "datarequestproject_id": 2, + "datatype_id": 4 } +} ] diff --git a/open_humans/templates/pages/public-data-api.html b/open_humans/templates/pages/public-data-api.html index 318af4f4a..20f316731 100644 --- a/open_humans/templates/pages/public-data-api.html +++ b/open_humans/templates/pages/public-data-api.html @@ -1,6 +1,7 @@ {% extends 'base.html' %} {% load static %} +{% load utilities %} {% block head_title %}The Public Data API{% endblock %} @@ -34,8 +35,8 @@

Instructions

The base URL for the API is - https://www.openhumans.org/api/public-data/. + href="{% domain %}/api/public-data/"> + {% domain %}/api/public-data/.

@@ -74,8 +75,8 @@

Examples

- https://www.openhumans.org/api/public-data/?username=madprime&limit=5 + href="{% domain %}/api/public-data/?username=madprime&limit=5"> + {% domain %}/api/public-data/?username=madprime&limit=5

@@ -86,8 +87,8 @@

Examples

- https://www.openhumans.org/api/public-data/?created_start=2/14/2016&created_end=2/14/2016&source=direct-sharing-139 + href="{% domain %}/api/public-data/?created_start=2/14/2016&created_end=2/14/2016&source=direct-sharing-139"> + {% domain %}/api/public-data/?created_start=2/14/2016&created_end=2/14/2016&source=direct-sharing-139

@@ -99,12 +100,12 @@

Which users have which sources?

Query for a specific data type

@@ -128,6 +129,48 @@

Query for a specific data type

URL:

+ +

Information about projects

+

+ You can query the projects endpoint to get information about projects: +

+

+ + {% domain %}/api/public-data/projects/ + +

+

+ You can filter this list by a project's `id_label` or `id`: +

+ + +

DataTypes

+

+ We've introduced DataTypes as a method for managing data sharing permissions. + Projects that add data to Open Humans accounts should register DataTypes for the data + they may upload. You can see a list of all DataTypes here: +

+

+ + {% domain %}/api/public-data/datatypes/ + +

+

+ You can also filter on a specific project label to see what DataTypes it has registered. +

+

+ + {% domain %}/api/public-data/datatypes/?source_project_label=direct-sharing-176 + +

{% endblock %} diff --git a/open_humans/templatetags/utilities.py b/open_humans/templatetags/utilities.py index b85f286b0..faf1959d0 100644 --- a/open_humans/templatetags/utilities.py +++ b/open_humans/templatetags/utilities.py @@ -370,3 +370,11 @@ def get_download_url(context, data_file): Returns the download url """ return data_file.download_url(context.request) + + +@register.simple_tag() +def domain(): + """ + For setting the currently set domain in html templates. + """ + return settings.DEFAULT_HTTP_PROTOCOL + "://" + settings.DOMAIN diff --git a/open_humans/tests.py b/open_humans/tests.py index 9eb6cc4b4..d9492be3c 100644 --- a/open_humans/tests.py +++ b/open_humans/tests.py @@ -38,6 +38,8 @@ class SmokeTests(SmokeTestCase): "/api/public-data/?created_start=2/14/2016&created_end=2/14/2016", "/api/public-data/sources-by-member/", "/api/public-data/members-by-source/", + "/api/public-data/datatypes/", + "/api/public-data/projects/", "/beau/", "/community-guidelines/", "/contact-us/", diff --git a/private_sharing/api_views.py b/private_sharing/api_views.py index 12e71dc9b..c8dbad733 100644 --- a/private_sharing/api_views.py +++ b/private_sharing/api_views.py @@ -210,7 +210,10 @@ def post(self, request): if project_member: req_data["project_member_id"] = project_member.project_member_id - form = self.form_class(req_data, request.FILES) + try: + form = self.form_class(req_data, request.FILES, project=project) + except TypeError: + form = self.form_class(req_data, request.FILES) if not form.is_valid(): raise serializers.ValidationError(form.errors) @@ -308,18 +311,19 @@ class ProjectFileDirectUploadView(ProjectFormBaseView): form_class = DirectUploadDataFileForm def post(self, request): - super(ProjectFileDirectUploadView, self).post(request) + super().post(request) key = get_upload_path(self.project.id_label, self.form.cleaned_data["filename"]) - data_file = ProjectDataFile( + datafile = ProjectDataFile( user=self.project_member.member.user, file=key, metadata=self.form.cleaned_data["metadata"], direct_sharing_project=self.project, ) - data_file.save() + datafile.save() + datafile.datatypes.set(self.form.cleaned_data["datatypes"]) s3 = S3Connection(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY) @@ -330,9 +334,7 @@ def post(self, request): key=key, ) - return Response( - {"id": data_file.id, "url": url}, status=status.HTTP_201_CREATED - ) + return Response({"id": datafile.id, "url": url}, status=status.HTTP_201_CREATED) class ProjectFileDirectUploadCompletionView(ProjectFormBaseView): @@ -343,11 +345,14 @@ class ProjectFileDirectUploadCompletionView(ProjectFormBaseView): form_class = DirectUploadDataFileCompletionForm def post(self, request): - super(ProjectFileDirectUploadCompletionView, self).post(request) - - data_file = ProjectDataFile.all_objects.get( - pk=self.form.cleaned_data["file_id"] - ) + super().post(request) + # If upload failed, file_id would be empty. Let's fail gracefully. + file_id = self.form.cleaned_data.get("file_id", None) + if not file_id: + return Response( + {"detail": "file does not exist"}, status=status.HTTP_400_BAD_REQUEST + ) + data_file = ProjectDataFile.all_objects.get(pk=file_id) data_file.completed = True data_file.save() @@ -374,17 +379,17 @@ class ProjectFileUploadView(ProjectFormBaseView): def post(self, request): super().post(request) - data_file = ProjectDataFile( + datafile = ProjectDataFile( user=self.project_member.member.user, file=self.form.cleaned_data["data_file"], metadata=self.form.cleaned_data["metadata"], direct_sharing_project=self.project, completed=True, ) + datafile.save() + datafile.datatypes.set(self.form.cleaned_data["datatypes"]) - data_file.save() - - return Response({"id": data_file.id}, status=status.HTTP_201_CREATED) + return Response({"id": datafile.id}, status=status.HTTP_201_CREATED) class ProjectFileDeleteView(ProjectFormBaseView): diff --git a/private_sharing/fixtures/test-data.json b/private_sharing/fixtures/test-data.json index 0b12a9186..df29061f5 100644 --- a/private_sharing/fixtures/test-data.json +++ b/private_sharing/fixtures/test-data.json @@ -168,5 +168,65 @@ "beau" ] } +}, +{ + "model": "data_import.datatype", + "pk": 1, + "fields": { + "name": "survey", + "description": "survey", + "created": "2016-02-24T22:11:38.721Z", + "modified": "2016-02-24T22:11:38.721Z", + "parent_id": null + } +}, +{ + "model": "data_import.datatype", + "pk": 2, + "fields": { + "name": "all your base", + "description": "are belong to us", + "created": "2016-03-24T22:11:38.721Z", + "modified": "2016-03-24T22:11:38.721Z", + "parent_id": 1 + } +}, +{ + "model": "data_import.datatype", + "pk": 3, + "fields": { + "name": "genetics", + "description": "genetics", + "created": "2016-02-24T22:11:38.721Z", + "modified": "2016-02-24T22:11:38.721Z", + "parent_id": null + } +}, +{ + "model": "data_import.datatype", + "pk": 4, + "fields": { + "name": "fourth one", + "description": "because I decided to have four", + "created": "2016-02-24T22:11:38.721Z", + "modified": "2016-02-24T22:11:38.721Z", + "parent_id": null + } +}, +{ + "model": "private_sharing.datarequestproject_registered_datatypes", + "pk": 1, + "fields": { + "datarequestproject_id": 3, + "datatype_id": 1 + } +}, +{ + "model": "private_sharing.datarequestproject_registered_datatypes", + "pk": 2, + "fields": { + "datarequestproject_id": 3, + "datatype_id": 4 + } } ] diff --git a/private_sharing/forms.py b/private_sharing/forms.py index df5d8073b..185ac2b15 100644 --- a/private_sharing/forms.py +++ b/private_sharing/forms.py @@ -6,6 +6,7 @@ import arrow from common import tasks +from data_import.models import DataType from .models import ( DataRequestProject, @@ -313,6 +314,79 @@ class UploadDataFileBaseForm(forms.Form): metadata = forms.CharField(label="Metadata", required=True) + # TODO: When updated to require datatypes - should set required to true. + datatypes = forms.CharField(label="Data type", required=False) + + def __init__(self, *args, **kwargs): + self.project = kwargs.pop("project") + super().__init__(*args, **kwargs) + + def _get_datatypes(self, dt_list, method): + """ + Get DataType objects from IDs or names, report if unmatched and unregistered. + """ + assert method in ["id", "name"] + datatypes, unregistered, unmatched = [], [], [] + registered_datatypes = self.project.registered_datatypes.all() + for item in dt_list: + try: + if method == "id": + dt = DataType.objects.get(id=int(item)) + elif method == "name": + dt = DataType.objects.get(name__iexact=item) + if dt in registered_datatypes: + datatypes.append(dt) + else: + unregistered.append(item) + except DataType.DoesNotExist: + unmatched.append(item) + return datatypes, unregistered, unmatched + + def clean_datatypes(self): + """ + Cleans and returns DataType objects specified by JSON list of IDs or names. + """ + # TODO: When updated to require datatypes - no longer handle missing field. + if not self.cleaned_data["datatypes"]: + if self.project.auto_add_datatypes: + return self.project.registered_datatypes.all() + else: + return [] + + # Load as JSON-fomatted list + try: + dt_set = set(json.loads(self.cleaned_data["datatypes"])) + except ValueError: + raise forms.ValidationError( + "Could not parse the uploaded datatypes from: '{}'".format( + self.cleaned_data["datatypes"] + ) + ) + + # Try loading as IDs first, then names. + try: + datatypes, notreg, notmatch = self._get_datatypes(dt_set, method="id") + except ValueError: + datatypes, notreg, notmatch = self._get_datatypes(dt_set, method="name") + + # Error fon unmatched datatypes. + if notmatch: + raise forms.ValidationError( + "The following datatypes don't match items in our database: {}".format( + ", ".join(notmatch) + ) + ) + + # Error for unregistered datatypes. + if notreg: + raise forms.ValidationError( + "The following datatypes aren't registered for this project: {}".format( + ", ".join(notreg) + ) + ) + + return datatypes + def clean_metadata(self): try: metadata = json.loads(self.cleaned_data["metadata"]) @@ -393,3 +467,18 @@ class DeleteDataFileForm(forms.Form): file_basename = forms.CharField(required=False, label="File basename") all_files = forms.BooleanField(required=False, label="All files") + + +class SelectDatatypesForm(forms.ModelForm): + """ + Select registered datatypes for a project. + """ + + class Meta: # noqa: D101 + model = DataRequestProject + fields = ["registered_datatypes"] + widgets = {"registered_datatypes": forms.CheckboxSelectMultiple} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["registered_datatypes"].required = False diff --git a/private_sharing/migrations/0020_auto_20190222_0036.py b/private_sharing/migrations/0020_auto_20190222_0036.py index 0cb4e6e3e..1be5f30cb 100644 --- a/private_sharing/migrations/0020_auto_20190222_0036.py +++ b/private_sharing/migrations/0020_auto_20190222_0036.py @@ -5,17 +5,13 @@ class Migration(migrations.Migration): - dependencies = [ - ('private_sharing', '0019_auto_20190214_1915'), - ] + dependencies = [("private_sharing", "0019_auto_20190214_1915")] operations = [ migrations.RemoveField( - model_name='datarequestproject', - name='request_sources_access', + model_name="datarequestproject", name="request_sources_access" ), migrations.RemoveField( - model_name='datarequestprojectmember', - name='sources_shared', + model_name="datarequestprojectmember", name="sources_shared" ), ] diff --git a/private_sharing/migrations/0021_auto_20190412_1908.py b/private_sharing/migrations/0021_auto_20190412_1908.py new file mode 100644 index 000000000..1d34271fd --- /dev/null +++ b/private_sharing/migrations/0021_auto_20190412_1908.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2 on 2019-04-12 19:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("data_import", "0019_datatype"), + ("private_sharing", "0020_auto_20190222_0036"), + ] + + operations = [ + migrations.AddField( + model_name="datarequestproject", + name="auto_add_datatypes", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="datarequestproject", + name="registered_datatypes", + field=models.ManyToManyField(to="data_import.DataType"), + ), + migrations.AddField( + model_name="projectdatafile", + name="datatypes", + field=models.ManyToManyField(to="data_import.DataType"), + ), + ] diff --git a/private_sharing/models.py b/private_sharing/models.py index c245dd9e7..11ba734a6 100644 --- a/private_sharing/models.py +++ b/private_sharing/models.py @@ -23,7 +23,7 @@ from oauth2_provider.models import AccessToken, Application, RefreshToken from common.utils import app_label_to_verbose_name, generate_id -from data_import.models import DataFile +from data_import.models import DataFile, DataType from open_humans.models import Member from open_humans.storage import PublicStorage @@ -228,6 +228,7 @@ class DataRequestProject(models.Model): ), verbose_name="Are you requesting Open Humans usernames?", ) + registered_datatypes = models.ManyToManyField(DataType) class Meta: ordering = ["name"] @@ -247,6 +248,7 @@ class Meta: token_expiration_date = models.DateTimeField(default=now_plus_24_hours) token_expiration_disabled = models.BooleanField(default=False) no_public_data = models.BooleanField(default=False) + auto_add_datatypes = models.BooleanField(default=False) def __init__(self, *args, **kwargs): # Adds self.old_approved so that we can detect when the field changes @@ -646,7 +648,7 @@ class ProjectDataFile(DataFile): related_name="parent_project_data_file", on_delete=models.CASCADE, ) - + datatypes = models.ManyToManyField(DataType) completed = models.BooleanField(default=False) direct_sharing_project = models.ForeignKey( DataRequestProject, on_delete=models.CASCADE diff --git a/private_sharing/serializers.py b/private_sharing/serializers.py index 4b20c58d3..1348abbe6 100644 --- a/private_sharing/serializers.py +++ b/private_sharing/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from common.utils import full_url -from data_import.models import DataFile +from data_import.models import DataFile, DataType from data_import.serializers import DataFileSerializer from .models import DataRequestProject, DataRequestProjectMember diff --git a/private_sharing/templates/private_sharing/project-detail.html b/private_sharing/templates/private_sharing/project-detail.html index 942c86d0d..3e05afd28 100644 --- a/private_sharing/templates/private_sharing/project-detail.html +++ b/private_sharing/templates/private_sharing/project-detail.html @@ -23,6 +23,9 @@

Project details

Remove members + + Configure uploaded datatypes


@@ -119,6 +122,12 @@

"{{ object.name }}"

{% endfor %} {% endif %} +
Registered datatypes
+
+ {% for datatype in object.registered_datatypes.all %} + {{ datatype.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
Username permission
{{ object.request_username_access }}
diff --git a/private_sharing/templates/private_sharing/select-datatypes.html b/private_sharing/templates/private_sharing/select-datatypes.html new file mode 100644 index 000000000..24e65bbaa --- /dev/null +++ b/private_sharing/templates/private_sharing/select-datatypes.html @@ -0,0 +1,49 @@ +{% extends 'panel.html' %} + +{% load bootstrap_tags %} +{% load static %} + +{% block head_title %}{{ object.name }}: Registered DataTypes{% endblock %} + +{% block panel_content %} +

+ Is your project potentially adding data to Open Humans? Please use this form + to register DataTypes. Files uploaded to member accounts must specify one + or more DataTypes registered here. +

+
+
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+

+ {% for error in form.non_field_errors%} + Error: {{ error }} + {% endfor %} +

+
+ {% endif %} + {% for item in datatypes_sorted %} + {# checkbox for each datatype, lefthand padding according to datatype depth #} +
+ - {{ item.datatype.description }} +
+ {% endfor %} +
+ +
+
+
+Don't see the DataType you need? +Go to the DataTypes section +to explore, update, and create DataTypes. +{% endblock %} diff --git a/private_sharing/testing.py b/private_sharing/testing.py index 7e11f22cb..882d68fab 100644 --- a/private_sharing/testing.py +++ b/private_sharing/testing.py @@ -2,8 +2,11 @@ from unittest import skipIf from django.conf import settings +from django.db.models import Count from common.testing import SmokeTestCase +from data_import.models import DataType +from open_humans.models import Member from .models import DataRequestProjectMember, ProjectDataFile @@ -22,6 +25,24 @@ def setUp(): """ DataRequestProjectMember.objects.all().delete() + def insert_datatypes(self): + editor = Member.objects.get(user__username="chickens") + while DataType.objects.all(): + DataType.objects.annotate(num_children=Count("children")).filter( + num_children=0 + ).delete() + for dt in range(1, 5): + new_datatype = DataType(name=str(dt)) + new_datatype.editor = editor + new_datatype.save() + new_datatype = DataType(name="all your base") + new_datatype.editor = editor + new_datatype.save() + new_datatype = DataType(name="are belong to us") + new_datatype.editor = editor + new_datatype.save() + return DataType.objects.all() + def update_member(self, joined, authorized, revoked=False): # first delete the ProjectMember try: @@ -55,6 +76,14 @@ class DirectSharingTestsMixin(object): @skipIf((not settings.AWS_STORAGE_BUCKET_NAME), "AWS bucket not set up.") def test_file_upload(self): member = self.update_member(joined=True, authorized=True) + datatypes = self.insert_datatypes() + self.member1_project.registered_datatypes.clear() + self.member1_project.registered_datatypes.add( + datatypes.get(name="all your base") + ) + self.member1_project.registered_datatypes.add( + datatypes.get(name="are belong to us") + ) response = self.client.post( "/api/direct-sharing/project/files/upload/?access_token={}".format( @@ -62,6 +91,7 @@ def test_file_upload(self): ), data={ "project_member_id": member.project_member_id, + "datatypes": '["all your base", "are belong to us"]', "metadata": ( '{"description": "Test description...", ' '"tags": ["tag 1", "tag 2", "tag 3"]}' @@ -88,6 +118,58 @@ def test_file_upload(self): self.assertEqual(data_file.file.readlines(), [b"just testing..."]) + def test_file_upload_bad_datatypes(self): + member = self.update_member(joined=True, authorized=True) + datatypes = self.insert_datatypes() + self.member1_project.registered_datatypes.clear() + self.member1_project.registered_datatypes.add( + datatypes.get(name="all your base") + ) + + # Test unregistered datatype. + response = self.client.post( + "/api/direct-sharing/project/files/upload/?access_token={}".format( + self.member1_project.master_access_token + ), + data={ + "project_member_id": member.project_member_id, + "datatypes": '["are belong to us"]', + "metadata": ( + '{"description": "Test description...", ' + '"tags": ["tag 1", "tag 2", "tag 3"]}' + ), + "data_file": StringIO("just testing..."), + }, + ) + + response_json = response.json() + + self.assertIn("datatypes", response_json) + self.assertRegexpMatches(response_json["datatypes"][0], "aren't registered") + self.assertEqual(response.status_code, 400) + + # Test datatype not found. + response = self.client.post( + "/api/direct-sharing/project/files/upload/?access_token={}".format( + self.member1_project.master_access_token + ), + data={ + "project_member_id": member.project_member_id, + "datatypes": '["you have no chance"]', + "metadata": ( + '{"description": "Test description...", ' + '"tags": ["tag 1", "tag 2", "tag 3"]}' + ), + "data_file": StringIO("just testing..."), + }, + ) + + response_json = response.json() + + self.assertIn("datatypes", response_json) + self.assertRegexpMatches(response_json["datatypes"][0], "don't match", msg=None) + self.assertEqual(response.status_code, 400) + def test_file_upload_bad_metadata(self): member = self.update_member(joined=True, authorized=True) @@ -247,6 +329,14 @@ def test_file_delete_bad_request(self): @skipIf((not settings.AWS_STORAGE_BUCKET_NAME), "AWS bucket not set up.") def test_direct_upload(self): member = self.update_member(joined=True, authorized=True) + datatypes = self.insert_datatypes() + self.member1_project.registered_datatypes.clear() + self.member1_project.registered_datatypes.add( + datatypes.get(name="all your base") + ) + self.member1_project.registered_datatypes.add( + datatypes.get(name="are belong to us") + ) response = self.client.post( "/api/direct-sharing/project/files/upload/direct/?access_token={}".format( @@ -255,6 +345,7 @@ def test_direct_upload(self): data={ "project_member_id": member.project_member_id, "filename": "test-file.json", + "registered_datatypes": '["all your base", "are belong to us"]', "metadata": ( '{"description": "Test description...", ' '"tags": ["tag 1", "tag 2", "tag 3"]}' @@ -269,3 +360,4 @@ def test_direct_upload(self): self.assertIn("/member-files/direct-sharing-", json["url"]) self.assertEqual(response.status_code, 201) + self.member1_project.registered_datatypes.clear() diff --git a/private_sharing/tests.py b/private_sharing/tests.py index e96a274b5..029cbbb1e 100644 --- a/private_sharing/tests.py +++ b/private_sharing/tests.py @@ -13,6 +13,7 @@ from oauth2_provider.models import AccessToken from common.testing import BrowserTestCase, get_or_create_user, SmokeTestCase +from data_import.models import DataType from open_humans.models import Member from .models import ( @@ -332,6 +333,14 @@ def test_oauth2_authorize(self): @unittest.skipIf((not settings.AWS_STORAGE_BUCKET_NAME), "AWS not set up.") def test_member_access_token(self): member = self.update_member(joined=True, authorized=True) + datatypes = self.insert_datatypes() + self.member1_project.registered_datatypes.clear() + self.member1_project.registered_datatypes.add( + datatypes.get(name="all your base") + ) + self.member1_project.registered_datatypes.add( + datatypes.get(name="are belong to us") + ) response = self.client.post( "/api/direct-sharing/project/files/upload/?access_token={}".format( @@ -339,6 +348,7 @@ def test_member_access_token(self): ), data={ "project_member_id": member.project_member_id, + "datatypes": '["all your base", "are belong to us"]', "metadata": ( '{"description": "Test description...", ' '"tags": ["tag 1", "tag 2", "tag 3"]}' diff --git a/private_sharing/urls.py b/private_sharing/urls.py index cacc72632..1d75f6c94 100644 --- a/private_sharing/urls.py +++ b/private_sharing/urls.py @@ -16,6 +16,11 @@ views.CreateOnSiteDataRequestProjectView.as_view(), name="create-on-site", ), + re_path( + r"^projects/registered_datatypes/(?P[a-z0-9_-]+)/$", + views.SelectDatatypesView.as_view(), + name="select-datatypes", + ), re_path( r"^projects/on-site/join/(?P[a-z0-9_-]+)/$", views.JoinOnSiteDataRequestProjectView.as_view(), diff --git a/private_sharing/views.py b/private_sharing/views.py index 9057f9652..366075287 100644 --- a/private_sharing/views.py +++ b/private_sharing/views.py @@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.http import Http404, HttpResponseRedirect from django.urls import reverse, reverse_lazy +from django.utils.safestring import SafeString from django.views.generic import ( CreateView, DetailView, @@ -12,21 +13,24 @@ UpdateView, View, ) +from django.views.generic.detail import SingleObjectMixin from common.mixins import LargePanelMixin, PrivateMixin from common.views import BaseOAuth2AuthorizationView +from data_import.models import DataType # TODO: move this to common from open_humans.mixins import SourcesContextMixin -from private_sharing.models import ActivityFeed from .forms import ( MessageProjectMembersForm, OAuth2DataRequestProjectForm, OnSiteDataRequestProjectForm, RemoveProjectMembersForm, + SelectDatatypesForm, ) from .models import ( + ActivityFeed, DataRequestProject, DataRequestProjectMember, OAuth2DataRequestProject, @@ -676,3 +680,52 @@ def get_object(self, queryset=None): self.object = queryset.get(slug=slug) return self.object + + +class SelectDatatypesView(PrivateMixin, CoordinatorOnlyView, UpdateView): + """ + Select the datatypes for a project. + """ + + form_class = SelectDatatypesForm + model = DataRequestProject + success_url = reverse_lazy("direct-sharing:manage-projects") + template_name = "private_sharing/select-datatypes.html" + + def dispatch(self, *args, **kwargs): + """ + Override dispatch to redirect if project is approved + """ + self.object = self.get_object() + if self.object.approved: + django_messages.error( + self.request, + ( + "Sorry, {0} has been approved and the project's datatypes cannot be changed " + "without re-approval.".format(self.object.name) + ), + ) + + return HttpResponseRedirect( + reverse( + "direct-sharing:detail-{0}".format(self.object.type), + kwargs={"slug": self.object.slug}, + ) + ) + return super().dispatch(*args, **kwargs) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + datatypes_sorted = DataType.sorted_by_ancestors() + try: + max_depth = max([i["depth"] for i in datatypes_sorted]) + except ValueError: + max_depth = 0 + context.update({"datatypes_sorted": datatypes_sorted, "max_depth": max_depth}) + return context + + def get_success_url(self): + return reverse_lazy( + "direct-sharing:detail-{0}".format(self.object.type), + args=[self.object.slug], + )