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.
+
+
+{% 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 %}
+
+ {% for project in object.datarequestproject_set.all %}
+ - {{ project.name }} {% if project.approved %}(approved){% endif %}
+ {% endfor %}
+
+{% endif %}
+
+{% if object.editable %}
+
+ Edit this entry
+
+{% endif %}
+Return to full list of DataTypes.
+{% if object.history_sorted %}
+History
+
+ {% for entry in object.history_sorted.items %}
+ -
+ {{ entry.0|date:"SHORT_DATETIME_FORMAT" }}
+
+
+ {% endfor %}
+
+{% 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
+
+
+{% for item in datatypes_sorted %}
+{# list each datatype, lefthand padding according to datatype depth #}
+-
+ {{ item.datatype.name }}
+
+{% endfor %}
+
+
+
+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 %}
+ |
+
+
+
+{% 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.
+
+
+
+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],
+ )