From 834026f8b427295a8b1400fffd89de0ee735033a Mon Sep 17 00:00:00 2001 From: Mairi Dulaney Date: Fri, 19 Apr 2019 15:49:16 -0700 Subject: [PATCH 01/10] Initial project creation API --- private_sharing/api_authentication.py | 38 +++++++- private_sharing/api_views.py | 93 ++++++++++++++++--- private_sharing/forms.py | 7 ++ .../migrations/0022_auto_20190419_2009.py | 21 +++++ ...23_oauth2datarequestproject_diy_project.py | 16 ++++ private_sharing/models.py | 3 + private_sharing/serializers.py | 51 +++++++++- 7 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 private_sharing/migrations/0022_auto_20190419_2009.py create mode 100644 private_sharing/migrations/0023_oauth2datarequestproject_diy_project.py diff --git a/private_sharing/api_authentication.py b/private_sharing/api_authentication.py index ccc9e5143..487e470ca 100644 --- a/private_sharing/api_authentication.py +++ b/private_sharing/api_authentication.py @@ -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 from rest_framework import exceptions from rest_framework.authentication import BaseAuthentication, get_authorization_header @@ -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. + :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 = ( + 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. diff --git a/private_sharing/api_views.py b/private_sharing/api_views.py index c8dbad733..cb02020c9 100644 --- a/private_sharing/api_views.py +++ b/private_sharing/api_views.py @@ -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 ( + 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. @@ -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,62 @@ 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 and description 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 elipse + 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. + """ + return "bacon" + + def post(self, request): + """ + Take incoming json and create a project from it + """ + serializer = ProjectCreationSerializer(data=request.data) + member = get_oauth2_member(request) + if serializer.is_valid(): + 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, + ) + # TODO: Coordinator join project + + # TODO: Generate redirect URL and save to project + + # Serialize project data for response + serialized_project = ProjectDataSerializer(project) + access_token, refresh_token = make_oauth2_tokens(project, member.user) + + # TODO: append tokens to the serialized_project data and return + + # TODO: Return error if serializer.is_valid() == False diff --git a/private_sharing/forms.py b/private_sharing/forms.py index 185ac2b15..fe2f4df33 100644 --- a/private_sharing/forms.py +++ b/private_sharing/forms.py @@ -127,6 +127,13 @@ class Meta: # noqa: D101 "deauth_webhook", ) + def __init__(self, *args, **kwargs): + """ + Set the redirect_url to be required + """ + super().__init__(*args, **kwargs) + self.fields["redirect_url"].required = True + class OnSiteDataRequestProjectForm(DataRequestProjectForm): """ diff --git a/private_sharing/migrations/0022_auto_20190419_2009.py b/private_sharing/migrations/0022_auto_20190419_2009.py new file mode 100644 index 000000000..45c96a2f7 --- /dev/null +++ b/private_sharing/migrations/0022_auto_20190419_2009.py @@ -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 read more about OAuth2\n "authorization code" transactions here.', + max_length=256, + verbose_name="Redirect URL", + ), + ) + ] diff --git a/private_sharing/migrations/0023_oauth2datarequestproject_diy_project.py b/private_sharing/migrations/0023_oauth2datarequestproject_diy_project.py new file mode 100644 index 000000000..8e48ad802 --- /dev/null +++ b/private_sharing/migrations/0023_oauth2datarequestproject_diy_project.py @@ -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), + ) + ] diff --git a/private_sharing/models.py b/private_sharing/models.py index 11ba734a6..8d9570424 100644 --- a/private_sharing/models.py +++ b/private_sharing/models.py @@ -395,6 +395,7 @@ class Meta: # noqa: D101 "/direct-sharing/oauth2-setup/#setup-oauth2-authorization" ), verbose_name="Redirect URL", + blank=True, ) deauth_webhook = models.URLField( @@ -408,6 +409,8 @@ class Meta: # noqa: D101 verbose_name="Deauthorization Webhook URL", ) + diy_project = models.BooleanField(default=False) + def save(self, *args, **kwargs): if hasattr(self, "application"): application = self.application diff --git a/private_sharing/serializers.py b/private_sharing/serializers.py index 1348abbe6..36abadc34 100644 --- a/private_sharing/serializers.py +++ b/private_sharing/serializers.py @@ -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): @@ -156,3 +160,48 @@ def to_representation(self, obj): rep.pop("username") return rep + + +class ProjectCreationSerializer(serializers.Serializer): + """ + In progress. + 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 + 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) + + def validate_name(self, value): + """ + Check the name + """ + if value: + return value + raise serializers.ValidationError("Please provide a name") + + def validate_long_description(self, value): + """ + Check the description + """ + if value: + return value + raise serializers.ValidationError("Please provide a Description") From 5f81ccfd64831c8453ff0e70d5c2ca92b8771d67 Mon Sep 17 00:00:00 2001 From: Mairi Dulaney Date: Mon, 22 Apr 2019 11:20:02 -0700 Subject: [PATCH 02/10] Further work on create project endpoint Signed-off-by: Mairi Dulaney --- private_sharing/api_views.py | 45 ++++++++++++++++++++++++++++------ private_sharing/serializers.py | 1 - 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/private_sharing/api_views.py b/private_sharing/api_views.py index cb02020c9..2eda324c9 100644 --- a/private_sharing/api_views.py +++ b/private_sharing/api_views.py @@ -478,6 +478,10 @@ class ProjectCreateAPIView(APIView): 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 /diyprojects//complete/ + The other required fields are auto-populated: is_study: set to False leader: set to member.name from oauth2 token @@ -497,14 +501,25 @@ def get_short_description(self, long_description): """ Return first 139 chars of long_description plus an elipse. """ - return "bacon" + return "{0}…".format(long_description[0:139]) def post(self, request): """ Take incoming json and create a project from it """ - serializer = ProjectCreationSerializer(data=request.data) + project_creation_project = DataRequestProject.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.pop("redirect-url-part", None) + if not redirect_url_part: + redirect_url_part = project_creation_project.enrollment_url + member = get_oauth2_member(request) + + serializer = ProjectCreationSerializer(data=request.data) if serializer.is_valid(): project = serializer.save( is_study=False, @@ -517,15 +532,31 @@ def post(self, request): ), coordinator=member, leader=member.name, + diy_project=True, ) - # TODO: Coordinator join project - # TODO: Generate redirect URL and save to project + # Coordinator join project + project_member = project.project_members.create(member=member) + project_member.consent_text = project.consent_text + 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 - serialized_project = ProjectDataSerializer(project) + # Copy data dict so that we can easily append fields + serialized_project = ProjectDataSerializer(project).data access_token, refresh_token = make_oauth2_tokens(project, member.user) - # TODO: append tokens to the serialized_project data and return + # append tokens to the serialized_project data + serialized_project["coordinator_access_token"] = access_token + serialized_project["coordinator_refresh_token"] = refresh_token + + return Response(serialized_project, status=status.HTTP_201_CREATED) - # TODO: Return error if serializer.is_valid() == False + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/private_sharing/serializers.py b/private_sharing/serializers.py index 36abadc34..6c76fd72f 100644 --- a/private_sharing/serializers.py +++ b/private_sharing/serializers.py @@ -164,7 +164,6 @@ def to_representation(self, obj): class ProjectCreationSerializer(serializers.Serializer): """ - In progress. Fields that we should be getting through the API: name long_description From 31139079dff57f84050dbf39776ac158e615e6a3 Mon Sep 17 00:00:00 2001 From: Mairi Dulaney Date: Mon, 22 Apr 2019 13:35:39 -0700 Subject: [PATCH 03/10] Add test for new endpoint Signed-off-by: Mairi Dulaney --- private_sharing/api_urls.py | 1 + private_sharing/api_views.py | 13 ++++--- private_sharing/serializers.py | 2 +- private_sharing/tests.py | 62 ++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/private_sharing/api_urls.py b/private_sharing/api_urls.py index 2d5658aef..ce6c6ad20 100644 --- a/private_sharing/api_urls.py +++ b/private_sharing/api_urls.py @@ -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) diff --git a/private_sharing/api_views.py b/private_sharing/api_views.py index 2eda324c9..500e2fe54 100644 --- a/private_sharing/api_views.py +++ b/private_sharing/api_views.py @@ -507,18 +507,17 @@ def post(self, request): """ Take incoming json and create a project from it """ - project_creation_project = DataRequestProject.objects.get( + 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.pop("redirect-url-part", None) + redirect_url_part = request.data.get("redirect-url-part", None) if not redirect_url_part: redirect_url_part = project_creation_project.enrollment_url - member = get_oauth2_member(request) - + member = get_oauth2_member(request).member serializer = ProjectCreationSerializer(data=request.data) if serializer.is_valid(): project = serializer.save( @@ -532,12 +531,12 @@ def post(self, request): ), 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.consent_text = project.consent_text project_member.joined = True project_member.authorized = True project_member.save() @@ -554,8 +553,8 @@ def post(self, request): access_token, refresh_token = make_oauth2_tokens(project, member.user) # append tokens to the serialized_project data - serialized_project["coordinator_access_token"] = access_token - serialized_project["coordinator_refresh_token"] = refresh_token + 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) diff --git a/private_sharing/serializers.py b/private_sharing/serializers.py index 6c76fd72f..88a46bca6 100644 --- a/private_sharing/serializers.py +++ b/private_sharing/serializers.py @@ -187,7 +187,7 @@ def create(self, validated_data): """ Returns a new OAuth2DataRequestProject """ - return OAuth2DataRequestProject.objects.create(validated_data) + return OAuth2DataRequestProject.objects.create(**validated_data) def validate_name(self, value): """ diff --git a/private_sharing/tests.py b/private_sharing/tests.py index 029cbbb1e..d0598f65b 100644 --- a/private_sharing/tests.py +++ b/private_sharing/tests.py @@ -16,6 +16,8 @@ from data_import.models import DataType from open_humans.models import Member +from .api_authentication import make_oauth2_tokens + from .models import ( DataRequestProject, DataRequestProjectMember, @@ -693,3 +695,63 @@ def test_returned_data_description_activity(self): '{}//p[@class="activity-description"]'.format(prefix) ).text self.assertIn("def", description) + + +@override_settings(SSLIFY_DISABLE=True) +class DirectSharingOAuth2ProjectAPITests(TestCase): + """ + Tests the project creation, update, delete API + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + president = get_or_create_user("Zaphod Beeblebrox") + cls.president, _ = Member.objects.get_or_create(user=president) + cls.project_creation_project = OAuth2DataRequestProject(name="Project 42") + cls.project_creation_project.enrollment_url = "http://127.0.0.1" + cls.project_creation_project.terms_url = "http://127.0.0.1" + cls.project_creation_project.redirect_url = "http://127.0.0.1/complete/" + cls.project_creation_project.is_study = False + cls.project_creation_project.leader = "Zaphod Beeblebrox" + cls.project_creation_project.organization = "Galactic Government" + cls.project_creation_project.is_academic_or_nonprofit = False + cls.project_creation_project.add_data = False + cls.project_creation_project.explore_share = False + cls.project_creation_project.short_description = "Infinite Improbability Drive" + cls.project_creation_project.long_description = ( + "A project to power a spacecraft via Infinite Improbability Drive" + ) + cls.project_creation_project.request_username_access = False + cls.project_creation_project.approved = True + cls.project_creation_project.coordinator = cls.president + cls.project_creation_project.save() + + project_member = cls.project_creation_project.project_members.create( + member=cls.president + ) + project_member.joined = True + project_member.authorized = True + project_member.save() + + def test_project_create_api(self): + + access_token, refresh_token = make_oauth2_tokens( + self.project_creation_project, self.president.user + ) + url = "/api/direct-sharing/project/oauth2/create/?access_token={0}".format( + str(access_token.token) + ) + + response = self.client.post( + url, + data={ + "name": "Stolen", + "long_description": "Stolen during the commissioning ceremony by the President of the Galazy. How wild is that? I guess it is the Improbability Drive, after all.", + }, + ) + self.assertEqual(response.status_code, 201) + new_project = OAuth2DataRequestProject.objects.get(id=response.data["id"]) + self.assertEqual(response.data["name"], "Stolen") + self.assertEqual(new_project.name, "Stolen") From 377b2f6944c4a88c12b6ab297f76e812eb005f33 Mon Sep 17 00:00:00 2001 From: Mairi Dulaney Date: Mon, 22 Apr 2019 13:48:37 -0700 Subject: [PATCH 04/10] Test for failure, too Signed-off-by: Mairi Dulaney --- private_sharing/tests.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/private_sharing/tests.py b/private_sharing/tests.py index d0598f65b..b02d3db4a 100644 --- a/private_sharing/tests.py +++ b/private_sharing/tests.py @@ -755,3 +755,7 @@ def test_project_create_api(self): new_project = OAuth2DataRequestProject.objects.get(id=response.data["id"]) self.assertEqual(response.data["name"], "Stolen") self.assertEqual(new_project.name, "Stolen") + + # Test for missing required args + response2 = self.client.post(url, data={"name": "Magrathea"}) + self.assertEqual(response2.status_code, 400) From 7fe7a499bb03bd1eea0d3625e53df20c1600287b Mon Sep 17 00:00:00 2001 From: Mairi Dulaney Date: Mon, 22 Apr 2019 13:58:35 -0700 Subject: [PATCH 05/10] remove unneeded code Signed-off-by: Mairi Dulaney --- private_sharing/serializers.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/private_sharing/serializers.py b/private_sharing/serializers.py index 88a46bca6..e8d68aaa0 100644 --- a/private_sharing/serializers.py +++ b/private_sharing/serializers.py @@ -188,19 +188,3 @@ def create(self, validated_data): Returns a new OAuth2DataRequestProject """ return OAuth2DataRequestProject.objects.create(**validated_data) - - def validate_name(self, value): - """ - Check the name - """ - if value: - return value - raise serializers.ValidationError("Please provide a name") - - def validate_long_description(self, value): - """ - Check the description - """ - if value: - return value - raise serializers.ValidationError("Please provide a Description") From b2dd9453e0d5595f2abb491854819f3fba7148a9 Mon Sep 17 00:00:00 2001 From: Mairi Dulaney Date: Tue, 23 Apr 2019 13:10:22 -0700 Subject: [PATCH 06/10] Add update view Signed-off-by: Mairi Dulaney --- private_sharing/api_urls.py | 1 + private_sharing/api_views.py | 31 +++++++++-- private_sharing/forms.py | 7 --- .../migrations/0022_auto_20190419_2009.py | 21 -------- ...oauth2datarequestproject_diyexperiment.py} | 6 +-- private_sharing/models.py | 3 +- private_sharing/serializers.py | 16 +++++- private_sharing/tests.py | 51 ++++++++++++++++++- 8 files changed, 95 insertions(+), 41 deletions(-) delete mode 100644 private_sharing/migrations/0022_auto_20190419_2009.py rename private_sharing/migrations/{0023_oauth2datarequestproject_diy_project.py => 0022_oauth2datarequestproject_diyexperiment.py} (64%) diff --git a/private_sharing/api_urls.py b/private_sharing/api_urls.py index ce6c6ad20..57d00b931 100644 --- a/private_sharing/api_urls.py +++ b/private_sharing/api_urls.py @@ -25,6 +25,7 @@ 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) diff --git a/private_sharing/api_views.py b/private_sharing/api_views.py index 500e2fe54..371e31005 100644 --- a/private_sharing/api_views.py +++ b/private_sharing/api_views.py @@ -40,7 +40,7 @@ ProjectDataFile, ) from .serializers import ( - ProjectCreationSerializer, + ProjectAPISerializer, ProjectDataSerializer, ProjectMemberDataSerializer, ) @@ -489,9 +489,8 @@ class ProjectCreateAPIView(APIView): is_academic_or_nonprofit: False add_data: false explore_share: false - short_description: first 139 chars of long_description plus an elipse + short_description: first 139 chars of long_description plus an ellipsis active: True - coordinator: from oauth2 token """ authentication_classes = (CustomOAuth2Authentication,) @@ -518,7 +517,7 @@ def post(self, request): redirect_url_part = project_creation_project.enrollment_url member = get_oauth2_member(request).member - serializer = ProjectCreationSerializer(data=request.data) + serializer = ProjectAPISerializer(data=request.data) if serializer.is_valid(): project = serializer.save( is_study=False, @@ -532,7 +531,7 @@ def post(self, request): coordinator=member, leader=member.name, request_username_access=False, - diy_project=True, + diyexperiment=True, ) # Coordinator join project @@ -559,3 +558,25 @@ def post(self, request): 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) diff --git a/private_sharing/forms.py b/private_sharing/forms.py index fe2f4df33..185ac2b15 100644 --- a/private_sharing/forms.py +++ b/private_sharing/forms.py @@ -127,13 +127,6 @@ class Meta: # noqa: D101 "deauth_webhook", ) - def __init__(self, *args, **kwargs): - """ - Set the redirect_url to be required - """ - super().__init__(*args, **kwargs) - self.fields["redirect_url"].required = True - class OnSiteDataRequestProjectForm(DataRequestProjectForm): """ diff --git a/private_sharing/migrations/0022_auto_20190419_2009.py b/private_sharing/migrations/0022_auto_20190419_2009.py deleted file mode 100644 index 45c96a2f7..000000000 --- a/private_sharing/migrations/0022_auto_20190419_2009.py +++ /dev/null @@ -1,21 +0,0 @@ -# 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 read more about OAuth2\n "authorization code" transactions here.', - max_length=256, - verbose_name="Redirect URL", - ), - ) - ] diff --git a/private_sharing/migrations/0023_oauth2datarequestproject_diy_project.py b/private_sharing/migrations/0022_oauth2datarequestproject_diyexperiment.py similarity index 64% rename from private_sharing/migrations/0023_oauth2datarequestproject_diy_project.py rename to private_sharing/migrations/0022_oauth2datarequestproject_diyexperiment.py index 8e48ad802..2e3dea3b7 100644 --- a/private_sharing/migrations/0023_oauth2datarequestproject_diy_project.py +++ b/private_sharing/migrations/0022_oauth2datarequestproject_diyexperiment.py @@ -1,16 +1,16 @@ -# Generated by Django 2.2 on 2019-04-19 21:15 +# Generated by Django 2.2 on 2019-04-23 20:01 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("private_sharing", "0022_auto_20190419_2009")] + dependencies = [("private_sharing", "0021_auto_20190412_1908")] operations = [ migrations.AddField( model_name="oauth2datarequestproject", - name="diy_project", + name="diyexperiment", field=models.BooleanField(default=False), ) ] diff --git a/private_sharing/models.py b/private_sharing/models.py index 8d9570424..b861151cd 100644 --- a/private_sharing/models.py +++ b/private_sharing/models.py @@ -395,7 +395,6 @@ class Meta: # noqa: D101 "/direct-sharing/oauth2-setup/#setup-oauth2-authorization" ), verbose_name="Redirect URL", - blank=True, ) deauth_webhook = models.URLField( @@ -409,7 +408,7 @@ class Meta: # noqa: D101 verbose_name="Deauthorization Webhook URL", ) - diy_project = models.BooleanField(default=False) + diyexperiment = models.BooleanField(default=False) def save(self, *args, **kwargs): if hasattr(self, "application"): diff --git a/private_sharing/serializers.py b/private_sharing/serializers.py index e8d68aaa0..ba84ebc3f 100644 --- a/private_sharing/serializers.py +++ b/private_sharing/serializers.py @@ -162,7 +162,7 @@ def to_representation(self, obj): return rep -class ProjectCreationSerializer(serializers.Serializer): +class ProjectAPISerializer(serializers.Serializer): """ Fields that we should be getting through the API: name @@ -180,11 +180,25 @@ class ProjectCreationSerializer(serializers.Serializer): 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(required=False) + diyexperiment = serializers.BooleanField(required=False) def create(self, validated_data): """ Returns a new OAuth2DataRequestProject """ 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 diff --git a/private_sharing/tests.py b/private_sharing/tests.py index b02d3db4a..301ae8df1 100644 --- a/private_sharing/tests.py +++ b/private_sharing/tests.py @@ -728,6 +728,27 @@ def setUpClass(cls): cls.project_creation_project.coordinator = cls.president cls.project_creation_project.save() + cls.project_update_project = OAuth2DataRequestProject(name="Milliways") + cls.project_update_project.long_description = ( + "The Hippest Place to watch all of Creation come to it's gasping end" + ) + cls.project_update_project.short_description = ( + "The Restaurant at the End of the Universe" + ) + cls.project_update_project.organization = "Milliways" + cls.project_update_project.leader = "Max Quordlepleen" + cls.project_update_project.enrollment_url = "http://127.0.0.1" + cls.project_update_project.terms_url = "http://127.0.0.1" + cls.project_update_project.redirect_url = "http://127.0.0.1/complete/" + cls.project_update_project.is_study = False + cls.project_update_project.is_academic_or_nonprofit = False + cls.project_update_project.add_data = False + cls.project_update_project.explore_share = False + cls.project_update_project.request_username_access = False + cls.project_update_project.approved = False + cls.project_update_project.coordinator = cls.president + cls.project_update_project.save() + project_member = cls.project_creation_project.project_members.create( member=cls.president ) @@ -735,13 +756,19 @@ def setUpClass(cls): project_member.authorized = True project_member.save() - def test_project_create_api(self): + update_project_member = cls.project_update_project.project_members.create( + member=cls.president + ) + update_project_member.joined = True + update_project_member.authorized = True + update_project_member.save() + def test_project_create_api(self): access_token, refresh_token = make_oauth2_tokens( self.project_creation_project, self.president.user ) url = "/api/direct-sharing/project/oauth2/create/?access_token={0}".format( - str(access_token.token) + access_token.token ) response = self.client.post( @@ -759,3 +786,23 @@ def test_project_create_api(self): # Test for missing required args response2 = self.client.post(url, data={"name": "Magrathea"}) self.assertEqual(response2.status_code, 400) + + def test_project_update_api(self): + access_token, refresh_token = make_oauth2_tokens( + self.project_update_project, self.president.user + ) + + url = "/api/direct-sharing/project/oauth2/update/?access_token={0}".format( + access_token.token + ) + new_long_description = ( + "Only the hoopiest of hoopies come here to watch it all ... end" + ) + + response = self.client.post( + url, data={"name": "Milliways", "long_description": new_long_description} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["long_description"], new_long_description) + project = OAuth2DataRequestProject.objects.get(name="Milliways") + self.assertEqual(project.long_description, new_long_description) From 8d1f6925d95b0573ca53d95ffb3285ba507d6133 Mon Sep 17 00:00:00 2001 From: Mairi Dulaney Date: Wed, 24 Apr 2019 10:04:35 -0700 Subject: [PATCH 07/10] stash Signed-off-by: Mairi Dulaney --- private_sharing/api_authentication.py | 6 +++--- private_sharing/api_views.py | 13 ++++++------- private_sharing/serializers.py | 2 +- private_sharing/tests.py | 8 +++++++- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/private_sharing/api_authentication.py b/private_sharing/api_authentication.py index 487e470ca..2c5e823a9 100644 --- a/private_sharing/api_authentication.py +++ b/private_sharing/api_authentication.py @@ -8,7 +8,7 @@ from oauth2_provider.models import AccessToken, RefreshToken from oauth2_provider.settings import oauth2_settings -from oauthlib import common +from oauthlib import common as oauth2lib_common from rest_framework import exceptions from rest_framework.authentication import BaseAuthentication, get_authorization_header @@ -35,13 +35,13 @@ def make_oauth2_tokens(project, user): user=user, scope="", expires=expires, - token=common.generate_token(), + token=oauth2lib_common.generate_token(), application=project.application, ) access_token.save() refresh_token = RefreshToken( user=user, - token=common.generate_token(), + token=oauth2lib_common.generate_token(), application=project.application, access_token=access_token, ) diff --git a/private_sharing/api_views.py b/private_sharing/api_views.py index 371e31005..316cd2470 100644 --- a/private_sharing/api_views.py +++ b/private_sharing/api_views.py @@ -500,7 +500,9 @@ def get_short_description(self, long_description): """ Return first 139 chars of long_description plus an elipse. """ - return "{0}…".format(long_description[0:139]) + if len(long_description) > 140: + return "{0}…".format(long_description[0:139]) + return long_description def post(self, request): """ @@ -533,6 +535,7 @@ def post(self, request): request_username_access=False, diyexperiment=True, ) + project.save() # Coordinator join project project_member = project.project_members.create(member=member) @@ -540,12 +543,6 @@ def post(self, request): 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 @@ -554,6 +551,8 @@ def post(self, request): # append tokens to the serialized_project data serialized_project["coordinator_access_token"] = access_token.token serialized_project["coordinator_refresh_token"] = refresh_token.token + serialized_project["client_id"] = project.application.client_id + serialized_project["client_secret"] = project.application.client_secret return Response(serialized_project, status=status.HTTP_201_CREATED) diff --git a/private_sharing/serializers.py b/private_sharing/serializers.py index ba84ebc3f..9a7d1e117 100644 --- a/private_sharing/serializers.py +++ b/private_sharing/serializers.py @@ -183,7 +183,7 @@ class ProjectAPISerializer(serializers.Serializer): id = serializers.IntegerField(required=False) name = serializers.CharField(max_length=100) long_description = serializers.CharField(max_length=1000) - redirect_url = serializers.URLField(required=False) + redirect_url = serializers.URLField() diyexperiment = serializers.BooleanField(required=False) def create(self, validated_data): diff --git a/private_sharing/tests.py b/private_sharing/tests.py index 301ae8df1..91852c1c9 100644 --- a/private_sharing/tests.py +++ b/private_sharing/tests.py @@ -776,6 +776,7 @@ def test_project_create_api(self): data={ "name": "Stolen", "long_description": "Stolen during the commissioning ceremony by the President of the Galazy. How wild is that? I guess it is the Improbability Drive, after all.", + "redirect_url": "http://localhost:7000/heart-of-gold/complete/", }, ) self.assertEqual(response.status_code, 201) @@ -800,7 +801,12 @@ def test_project_update_api(self): ) response = self.client.post( - url, data={"name": "Milliways", "long_description": new_long_description} + url, + data={ + "name": "Milliways", + "long_description": new_long_description, + "redirect_url": "http://localhost:7000/dinner-at-milliways/complete/", + }, ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["long_description"], new_long_description) From 586d2d9778e4276d55031f2d3445a3ccbff5822a Mon Sep 17 00:00:00 2001 From: Mairi Dulaney Date: Wed, 24 Apr 2019 10:32:18 -0700 Subject: [PATCH 08/10] Address comments in PR Signed-off-by: Mairi Dulaney --- private_sharing/api_authentication.py | 10 +++++----- private_sharing/api_views.py | 16 +--------------- private_sharing/serializers.py | 3 ++- 3 files changed, 8 insertions(+), 21 deletions(-) diff --git a/private_sharing/api_authentication.py b/private_sharing/api_authentication.py index 2c5e823a9..e210f729b 100644 --- a/private_sharing/api_authentication.py +++ b/private_sharing/api_authentication.py @@ -1,8 +1,9 @@ -from datetime import datetime, timedelta +from datetime import timedelta import arrow from django.contrib.auth import get_user_model +from django.utils import timezone from oauth2_provider.contrib.rest_framework import OAuth2Authentication from oauth2_provider.models import AccessToken, RefreshToken @@ -27,10 +28,9 @@ def make_oauth2_tokens(project, user): """ if not project.__class__ == OAuth2DataRequestProject: return None - expires = ( - datetime.utcnow() - + timedelta(seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - ).astimezone() + expires = timezone.now() + timedelta( + seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) access_token = AccessToken( user=user, scope="", diff --git a/private_sharing/api_views.py b/private_sharing/api_views.py index 316cd2470..dca2a7543 100644 --- a/private_sharing/api_views.py +++ b/private_sharing/api_views.py @@ -476,11 +476,7 @@ class ProjectCreateAPIView(APIView): """ Create a project 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 /diyprojects//complete/ + Accepts project name, description, and redirect_url as (required) inputs The other required fields are auto-populated: is_study: set to False @@ -508,16 +504,6 @@ 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) - if not redirect_url_part: - redirect_url_part = project_creation_project.enrollment_url - member = get_oauth2_member(request).member serializer = ProjectAPISerializer(data=request.data) if serializer.is_valid(): diff --git a/private_sharing/serializers.py b/private_sharing/serializers.py index 9a7d1e117..b00a3fa76 100644 --- a/private_sharing/serializers.py +++ b/private_sharing/serializers.py @@ -3,7 +3,7 @@ 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 ( @@ -167,6 +167,7 @@ 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 From aee1726d73b712e4586c01d0c9ed8ff6d215a86f Mon Sep 17 00:00:00 2001 From: Mairi Dulaney Date: Wed, 24 Apr 2019 11:04:58 -0700 Subject: [PATCH 09/10] Make coordinator join project optional Signed-off-by: Mairi Dulaney --- private_sharing/api_views.py | 18 +++++++++++------- private_sharing/serializers.py | 3 +++ private_sharing/tests.py | 10 ++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/private_sharing/api_views.py b/private_sharing/api_views.py index dca2a7543..f25e8e637 100644 --- a/private_sharing/api_views.py +++ b/private_sharing/api_views.py @@ -507,6 +507,7 @@ def post(self, request): 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, @@ -524,22 +525,25 @@ def post(self, request): project.save() # Coordinator join project - project_member = project.project_members.create(member=member) - project_member.joined = True - project_member.authorized = True - project_member.save() + 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 - 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 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) diff --git a/private_sharing/serializers.py b/private_sharing/serializers.py index b00a3fa76..1375bfbcc 100644 --- a/private_sharing/serializers.py +++ b/private_sharing/serializers.py @@ -186,11 +186,14 @@ class ProjectAPISerializer(serializers.Serializer): 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): diff --git a/private_sharing/tests.py b/private_sharing/tests.py index 91852c1c9..29f9c492c 100644 --- a/private_sharing/tests.py +++ b/private_sharing/tests.py @@ -777,6 +777,7 @@ def test_project_create_api(self): "name": "Stolen", "long_description": "Stolen during the commissioning ceremony by the President of the Galazy. How wild is that? I guess it is the Improbability Drive, after all.", "redirect_url": "http://localhost:7000/heart-of-gold/complete/", + "coordinator_join": True, }, ) self.assertEqual(response.status_code, 201) @@ -784,6 +785,15 @@ def test_project_create_api(self): self.assertEqual(response.data["name"], "Stolen") self.assertEqual(new_project.name, "Stolen") + # Check that the coordinator has, indeed, joined the project and been provided + # with a valid access_token + access_token = response.data["coordinator_access_token"] + url1 = "/api/direct-sharing/project/exchange-member/?access_token={0}".format( + access_token + ) + response1 = self.client.get(url1) + self.assertEqual(response1.status_code, 200) + # Test for missing required args response2 = self.client.post(url, data={"name": "Magrathea"}) self.assertEqual(response2.status_code, 400) From f664b22aaea0d24a3f0e60dcf5f284d90a3dbdf9 Mon Sep 17 00:00:00 2001 From: Mairi Dulaney Date: Thu, 25 Apr 2019 12:02:38 -0700 Subject: [PATCH 10/10] add logic for diyexperiment Signed-off-by: Mairi Dulaney --- private_sharing/api_permissions.py | 18 ++++++++++++++++++ private_sharing/api_views.py | 23 +++++++++++++++++++++-- private_sharing/views.py | 11 +++++++++-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/private_sharing/api_permissions.py b/private_sharing/api_permissions.py index 32809c616..04fc96962 100644 --- a/private_sharing/api_permissions.py +++ b/private_sharing/api_permissions.py @@ -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 diff --git a/private_sharing/api_views.py b/private_sharing/api_views.py index a2d1dba42..177363ce3 100644 --- a/private_sharing/api_views.py +++ b/private_sharing/api_views.py @@ -24,7 +24,7 @@ MasterTokenAuthentication, ) from .api_filter_backends import ProjectFilterBackend -from .api_permissions import HasValidProjectToken +from .api_permissions import CanProjectAccessData, HasValidProjectToken from .forms import ( DeleteDataFileForm, DirectUploadDataFileForm, @@ -111,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. @@ -129,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 @@ -146,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 @@ -156,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 @@ -197,6 +215,7 @@ class ProjectMemberDataView(ProjectListView): """ authentication_classes = (MasterTokenAuthentication,) + permission_classes = (CanProjectAccessData,) serializer_class = ProjectMemberDataSerializer max_limit = 20 default_limit = 10 diff --git a/private_sharing/views.py b/private_sharing/views.py index 366075287..558bdf82d 100644 --- a/private_sharing/views.py +++ b/private_sharing/views.py @@ -58,7 +58,14 @@ def dispatch(self, *args, **kwargs): if not project.active: raise Http404 - if not project.approved and project.authorized_members > MAX_UNAPPROVED_MEMBERS: + # Set a flag based on the combination of whether diyexperiment is set for oauth2 + # and project approval + if (project.__class__ == OAuth2DataRequestProject) and project.diyexperiment: + approval = True + else: + approval = project.approved + + if not approval and project.authorized_members > MAX_UNAPPROVED_MEMBERS: django_messages.error( self.request, ( @@ -71,7 +78,7 @@ def dispatch(self, *args, **kwargs): return HttpResponseRedirect(reverse("my-member-data")) - return super(CoordinatorOrActiveMixin, self).dispatch(*args, **kwargs) + return super().dispatch(*args, **kwargs) class ProjectMemberMixin(object):