-
Notifications
You must be signed in to change notification settings - Fork 22
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
Changes from all commits
834026f
5f81ccf
3113907
377b2f6
7fe7a49
b2dd945
8d1f692
586d2d9
aee1726
1e3effc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 ( | ||
|
@@ -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. | ||
|
@@ -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): | ||
|
@@ -458,3 +470,102 @@ def post(self, request): | |
data_file.delete() | ||
|
||
return Response({"ids": ids}, status=status.HTTP_200_OK) | ||
|
||
|
||
class ProjectCreateAPIView(APIView): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OAuth2ProjectCreateAPIView |
||
""" | ||
Create a project via API | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OAuth2ProjectUpdateAPIView |
||
""" | ||
API Endpoint to update a project. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), | ||
) | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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): | ||
|
@@ -156,3 +160,49 @@ def to_representation(self, obj): | |
rep.pop("username") | ||
|
||
return rep | ||
|
||
|
||
class ProjectAPISerializer(serializers.Serializer): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
There was a problem hiding this comment.
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.