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
38 changes: 37 additions & 1 deletion private_sharing/api_authentication.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from datetime import datetime, timedelta

import arrow

from django.contrib.auth import get_user_model

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
mldulaney marked this conversation as resolved.
Show resolved Hide resolved

from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication, get_authorization_header
Expand All @@ -13,6 +18,37 @@
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 = (
mldulaney marked this conversation as resolved.
Show resolved Hide resolved
datetime.utcnow()
+ timedelta(seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS)
).astimezone()
access_token = AccessToken(
user=user,
scope="",
expires=expires,
token=common.generate_token(),
application=project.application,
)
access_token.save()
refresh_token = RefreshToken(
user=user,
token=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
1 change: 1 addition & 0 deletions private_sharing/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"project/files/upload/complete/",
api_views.ProjectFileDirectUploadCompletionView.as_view(),
),
path("project/oauth2/create/", api_views.ProjectCreateAPIView.as_view()),
]

urlpatterns = format_suffix_patterns(urlpatterns)
123 changes: 112 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 (
ProjectCreationSerializer,
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,92 @@ 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 and description as (required) inputs

A third input that should be provided is the first part of the redirect url;
this will get the new project's slug appended to it to form the new project's
oauth2 redirect url, eg <mysuperspiffydomain>/diyprojects/<new-project-slug>/complete/

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 elipse
mldulaney marked this conversation as resolved.
Show resolved Hide resolved
active: True
coordinator: from oauth2 token
"""

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

"""
return "{0}…".format(long_description[0:139])
mldulaney marked this conversation as resolved.
Show resolved Hide resolved

def post(self, request):
"""
Take incoming json and create a project from it
"""
project_creation_project = OAuth2DataRequestProject.objects.get(
pk=self.request.auth.pk
)

# If the first part of the redirect_url is provided, grab that, otherwise set
# to the project-creation-project's enrollment_url as a usable default
redirect_url_part = request.data.get("redirect-url-part", None)
mldulaney marked this conversation as resolved.
Show resolved Hide resolved
if not redirect_url_part:
redirect_url_part = project_creation_project.enrollment_url

member = get_oauth2_member(request).member
serializer = ProjectCreationSerializer(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)

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,
diy_project=True,
)

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

# Generate redirect URL and save to project
project.redirect_url = "{0}/{1}/complete/".format(
redirect_url_part, project.slug
)
project.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
access_token, refresh_token = make_oauth2_tokens(project, member.user)

# append tokens to the serialized_project data
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)
7 changes: 7 additions & 0 deletions private_sharing/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ class Meta: # noqa: D101
"deauth_webhook",
)

def __init__(self, *args, **kwargs):
mldulaney marked this conversation as resolved.
Show resolved Hide resolved
"""
Set the redirect_url to be required
"""
super().__init__(*args, **kwargs)
self.fields["redirect_url"].required = True


class OnSiteDataRequestProjectForm(DataRequestProjectForm):
"""
Expand Down
21 changes: 21 additions & 0 deletions private_sharing/migrations/0022_auto_20190419_2009.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 2.2 on 2019-04-19 20:09

from django.db import migrations, models


class Migration(migrations.Migration):

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

operations = [
migrations.AlterField(
model_name="oauth2datarequestproject",
name="redirect_url",
field=models.CharField(
blank=True,
help_text='The return URL for our "authorization code" OAuth2 grant\n process. You can <a target="_blank" href="/direct-sharing/oauth2-setup/#setup-oauth2-authorization">read more about OAuth2\n "authorization code" transactions here</a>.',
max_length=256,
verbose_name="Redirect URL",
),
)
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Generated by Django 2.2 on 2019-04-19 21:15

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [("private_sharing", "0022_auto_20190419_2009")]

operations = [
migrations.AddField(
model_name="oauth2datarequestproject",
name="diy_project",
field=models.BooleanField(default=False),
)
]
3 changes: 3 additions & 0 deletions private_sharing/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ class Meta: # noqa: D101
"/direct-sharing/oauth2-setup/#setup-oauth2-authorization"
),
verbose_name="Redirect URL",
blank=True,
mldulaney marked this conversation as resolved.
Show resolved Hide resolved
)

deauth_webhook = models.URLField(
Expand All @@ -408,6 +409,8 @@ class Meta: # noqa: D101
verbose_name="Deauthorization Webhook URL",
)

diy_project = models.BooleanField(default=False)
mldulaney marked this conversation as resolved.
Show resolved Hide resolved

def save(self, *args, **kwargs):
if hasattr(self, "application"):
application = self.application
Expand Down
34 changes: 33 additions & 1 deletion private_sharing/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
from data_import.models import DataFile, DataType
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,31 @@ def to_representation(self, obj):
rep.pop("username")

return rep


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

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
"""

name = serializers.CharField(max_length=100)
long_description = serializers.CharField(max_length=1000)

def create(self, validated_data):
"""
Returns a new OAuth2DataRequestProject
"""
return OAuth2DataRequestProject.objects.create(**validated_data)
Loading