Skip to content
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