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 #1034

Closed
Closed
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creates and returns a tuple, AccessToken and RefreshToken, given a project and user.

^^^ this avoids implying a lookup might occur -- it's going to create these.

:param project: An oath2 project
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: OAuth2

: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
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)
133 changes: 122 additions & 11 deletions private_sharing/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
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 .forms import (
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 @@ -458,3 +470,102 @@ def post(self, request):
data_file.delete()

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


class ProjectCreateAPIView(APIView):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OAuth2ProjectCreateAPIView

"""
Create a project via API
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creates an OAuth2DataRequestProject 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: ellipsis

"""
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():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our other APIs use re-use the on site forms for validation: https://github.com/OpenHumans/open-humans/blob/master/open_humans/api_views.py

I think we should be doing the same here, to have consistency in validation (i.e. re-using OAuth2DataRequestProjectForm)

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should default to False and be True if the parameter was provided and is 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
mldulaney marked this conversation as resolved.
Show resolved Hide resolved

# 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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OAuth2ProjectUpdateAPIView

"""
API Endpoint to update a project.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... to update an OAuth2DataRequestProject

"""

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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OAuth2ProjectSerializer

"""
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: ellipsis

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