Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Project creation and update API and diyexperiment logic #1038

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
38 changes: 37 additions & 1 deletion private_sharing/api_authentication.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from datetime import timedelta

import arrow

from django.contrib.auth import get_user_model
from django.utils import timezone

from oauth2_provider.models import AccessToken
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from oauth2_provider.models import AccessToken, RefreshToken
from oauth2_provider.settings import oauth2_settings

from oauthlib import common as oauth2lib_common

from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication, get_authorization_header
Expand All @@ -13,6 +19,36 @@
UserModel = get_user_model()


def make_oauth2_tokens(project, user):
"""
Returns a tuple, an AccessToken object and a RefreshToken object given a project and a user.
:param project: An oath2 project
:param user: The user for the access token and refresh token
If project is not a valid oauth2datarequestproject, returns None
"""
if not project.__class__ == OAuth2DataRequestProject:
return None
expires = timezone.now() + timedelta(
seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS
)
access_token = AccessToken(
user=user,
scope="",
expires=expires,
token=oauth2lib_common.generate_token(),
application=project.application,
)
access_token.save()
refresh_token = RefreshToken(
user=user,
token=oauth2lib_common.generate_token(),
application=project.application,
access_token=access_token,
)
refresh_token.save()
return (access_token, refresh_token)


class MasterTokenAuthentication(BaseAuthentication):
"""
Master token based authentication.
Expand Down
18 changes: 18 additions & 0 deletions private_sharing/api_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,21 @@ class HasValidProjectToken(BasePermission):

def has_permission(self, request, view):
return bool(request.auth)


class CanProjectAccessData(BasePermission):
"""
Return true if any of the following conditions are met:
On Site project
Approved OAuth2 project
UnApproved OAuth2 project with diyexperiment=False
"""

def has_permission(self, request, view):
if hasattr(request.auth, "onsitedatarequestproject"):
return True
if request.auth.approved == True:
return True
if request.auth.oauth2datarequestproject.diyexperiment == False:
return True
return False
2 changes: 2 additions & 0 deletions private_sharing/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"project/files/upload/complete/",
api_views.ProjectFileDirectUploadCompletionView.as_view(),
),
path("project/oauth2/create/", api_views.ProjectCreateAPIView.as_view()),
path("project/oauth2/update/", api_views.ProjectUpdateAPIView.as_view()),
]

urlpatterns = format_suffix_patterns(urlpatterns)
156 changes: 143 additions & 13 deletions private_sharing/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@
from data_import.serializers import DataFileSerializer
from data_import.utils import get_upload_path

from .api_authentication import CustomOAuth2Authentication, MasterTokenAuthentication
from .api_authentication import (
make_oauth2_tokens,
CustomOAuth2Authentication,
MasterTokenAuthentication,
)
from .api_filter_backends import ProjectFilterBackend
from .api_permissions import HasValidProjectToken
from .api_permissions import CanProjectAccessData, HasValidProjectToken
from .forms import (
DeleteDataFileForm,
DirectUploadDataFileForm,
Expand All @@ -35,11 +39,27 @@
OAuth2DataRequestProject,
ProjectDataFile,
)
from .serializers import ProjectDataSerializer, ProjectMemberDataSerializer
from .serializers import (
ProjectAPISerializer,
ProjectDataSerializer,
ProjectMemberDataSerializer,
)

UserModel = get_user_model()


def get_oauth2_member(request):
"""
Return project member if auth by OAuth2 user access token, else None.
"""
if request.auth.__class__ == OAuth2DataRequestProject:
proj_member = DataRequestProjectMember.objects.get(
member=request.user.member, project=request.auth
)
return proj_member
return None


class ProjectAPIView(NeverCacheMixin):
"""
The base class for all Project-related API views.
Expand All @@ -49,15 +69,7 @@ class ProjectAPIView(NeverCacheMixin):
permission_classes = (HasValidProjectToken,)

def get_oauth2_member(self):
"""
Return project member if auth by OAuth2 user access token, else None.
"""
if self.request.auth.__class__ == OAuth2DataRequestProject:
proj_member = DataRequestProjectMember.objects.get(
member=self.request.user.member, project=self.request.auth
)
return proj_member
return None
return get_oauth2_member(self.request)


class ProjectDetailView(ProjectAPIView, RetrieveAPIView):
Expand Down Expand Up @@ -99,6 +111,16 @@ class ProjectMemberExchangeView(NeverCacheMixin, ListAPIView):
max_limit = 200
default_limit = 100

def diy_approved(self):
"""
Returns false if diyexperiment is set to True and approved is set to false,
otherwise returns True
"""
if hasattr(self.obj.project, "oauth2datarequestproject"):
if self.obj.project.oauth2datarequestproject.diyexperiment:
return self.obj.project.approved
return True

def get_object(self):
"""
Get the project member related to the access_token.
Expand All @@ -117,6 +139,9 @@ def get_object(self):
project_member = DataRequestProjectMember.objects.filter(
project_member_id=project_member_id, project=self.request.auth
)
else:
# We hit some weirdness if you inadvertantly use the master access token
project_member = DataRequestProjectMember.objects.none()
if project_member.count() == 1:
return project_member.get()
# No or invalid project_member_id provided
Expand All @@ -134,7 +159,7 @@ def get_username(self):
"""
Only return the username if the user has shared it with the project.
"""
if self.obj.username_shared:
if self.obj.username_shared and self.diy_approved():
return self.obj.member.user.username

return None
Expand All @@ -144,6 +169,11 @@ def get_queryset(self):
Get the queryset of DataFiles that belong to a member in a project
"""
self.obj = self.get_object()

# If this is an unapproved DIY project, we need to not return anything
if not self.diy_approved():
return DataFile.objects.none()

self.request.public_sources = list(
self.obj.member.public_data_participant.publicdataaccess_set.filter(
is_public=True
Expand Down Expand Up @@ -185,6 +215,7 @@ class ProjectMemberDataView(ProjectListView):
"""

authentication_classes = (MasterTokenAuthentication,)
permission_classes = (CanProjectAccessData,)
serializer_class = ProjectMemberDataSerializer
max_limit = 20
default_limit = 10
Expand Down Expand Up @@ -458,3 +489,102 @@ def post(self, request):
data_file.delete()

return Response({"ids": ids}, status=status.HTTP_200_OK)


class ProjectCreateAPIView(APIView):
"""
Create a project via API

Accepts project name, description, and redirect_url as (required) inputs

The other required fields are auto-populated:
is_study: set to False
leader: set to member.name from oauth2 token
coordinator: get from oauth2 token
is_academic_or_nonprofit: False
add_data: false
explore_share: false
short_description: first 139 chars of long_description plus an ellipsis
active: True
"""

authentication_classes = (CustomOAuth2Authentication,)
permission_classes = (HasValidProjectToken,)

def get_short_description(self, long_description):
"""
Return first 139 chars of long_description plus an elipse.
"""
if len(long_description) > 140:
return "{0}…".format(long_description[0:139])
return long_description

def post(self, request):
"""
Take incoming json and create a project from it
"""
member = get_oauth2_member(request).member
serializer = ProjectAPISerializer(data=request.data)
if serializer.is_valid():
coordinator_join = serializer.validated_data.get("coordinator_join", False)
project = serializer.save(
is_study=False,
is_academic_or_nonprofit=False,
add_data=False,
explore_share=False,
active=True,
short_description=self.get_short_description(
serializer.validated_data["long_description"]
),
coordinator=member,
leader=member.name,
request_username_access=False,
diyexperiment=True,
)
project.save()

# Coordinator join project
if coordinator_join:
project_member = project.project_members.create(member=member)
project_member.joined = True
project_member.authorized = True
project_member.save()

# Serialize project data for response
# Copy data dict so that we can easily append fields
serialized_project = ProjectDataSerializer(project).data

# append tokens to the serialized_project data
serialized_project["client_id"] = project.application.client_id
serialized_project["client_secret"] = project.application.client_secret

if coordinator_join:
access_token, refresh_token = make_oauth2_tokens(project, member.user)
serialized_project["coordinator_access_token"] = access_token.token
serialized_project["coordinator_refresh_token"] = refresh_token.token

return Response(serialized_project, status=status.HTTP_201_CREATED)

return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class ProjectUpdateAPIView(APIView):
"""
API Endpoint to update a project.
"""

authentication_classes = (CustomOAuth2Authentication,)
permission_classes = (HasValidProjectToken,)

def post(self, request):
"""
Take incoming json and update a project from it
"""
project = OAuth2DataRequestProject.objects.get(pk=self.request.auth.pk)
serializer = ProjectAPISerializer(project, data=request.data)
if serializer.is_valid():
# serializer.save() returns the modified object, but it is not written
# to the database, hence the second save()
serializer.save().save()
return Response(serializer.validated_data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Generated by Django 2.2 on 2019-04-23 20:01

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [("private_sharing", "0021_auto_20190412_1908")]

operations = [
migrations.AddField(
model_name="oauth2datarequestproject",
name="diyexperiment",
field=models.BooleanField(default=False),
)
]
2 changes: 2 additions & 0 deletions private_sharing/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ class Meta: # noqa: D101
verbose_name="Deauthorization Webhook URL",
)

diyexperiment = models.BooleanField(default=False)

def save(self, *args, **kwargs):
if hasattr(self, "application"):
application = self.application
Expand Down
54 changes: 52 additions & 2 deletions private_sharing/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
from rest_framework import serializers

from common.utils import full_url
from data_import.models import DataFile, DataType
from data_import.models import DataFile
from data_import.serializers import DataFileSerializer

from .models import DataRequestProject, DataRequestProjectMember
from .models import (
DataRequestProject,
DataRequestProjectMember,
OAuth2DataRequestProject,
)


class ProjectDataSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -156,3 +160,49 @@ def to_representation(self, obj):
rep.pop("username")

return rep


class ProjectAPISerializer(serializers.Serializer):
"""
Fields that we should be getting through the API:
name
long_description
redirect_url

Remainder of required fields; these are set at save() in the view.
is_study: set to False
leader: set to member.name from oauth2 token
coordinator: get from oauth2 token
is_academic_or_nonprofit: False
add_data: false
explore_share: false
short_description: first 139 chars of long_description plus an elipse
active: True
coordinator: from oauth2 token
"""

id = serializers.IntegerField(required=False)
name = serializers.CharField(max_length=100)
long_description = serializers.CharField(max_length=1000)
redirect_url = serializers.URLField()
diyexperiment = serializers.BooleanField(required=False)
coordinator_join = serializers.BooleanField(default=False, required=False)

def create(self, validated_data):
"""
Returns a new OAuth2DataRequestProject
"""
# Remove coordinator_join field as that doesn't actually exist in the model
validated_data.pop("coordinator_join")
return OAuth2DataRequestProject.objects.create(**validated_data)

def update(self, instance, validated_data):
"""
Updates existing OAuth2DataRequestProject
"""

for key, value in validated_data.items():
if hasattr(instance, key):
setattr(instance, key, value)

return instance
Loading