From de85529957b244a0f193ed49e5e7e2af02f976f9 Mon Sep 17 00:00:00 2001 From: hadleyking Date: Thu, 4 Apr 2024 20:26:35 -0400 Subject: [PATCH] Add DraftsModifyApi Changes to be committed: modified: biocompute/apis.py modified: biocompute/selectors.py modified: biocompute/services.py modified: biocompute/urls.py modified: prefix/selectors.py deleted: tests/test_apis/test_biocompute/objects_drafts_create.py deleted: tests/test_apis/test_biocompute/test_objects_drafts_create.py --- biocompute/apis.py | 139 +++++++++++++++++- biocompute/selectors.py | 37 +++-- biocompute/services.py | 131 ++++++++++++++++- biocompute/urls.py | 5 +- prefix/selectors.py | 17 +++ .../test_biocompute/objects_drafts_create.py | 135 ----------------- .../test_objects_drafts_create.py | 135 ----------------- 7 files changed, 306 insertions(+), 293 deletions(-) delete mode 100644 tests/test_apis/test_biocompute/objects_drafts_create.py delete mode 100644 tests/test_apis/test_biocompute/test_objects_drafts_create.py diff --git a/biocompute/apis.py b/biocompute/apis.py index dfa6758f..94b01f22 100644 --- a/biocompute/apis.py +++ b/biocompute/apis.py @@ -14,8 +14,8 @@ from rest_framework.response import Response from tests.fixtures.example_bco import BCO_000001 from config.services import legacy_api_converter, response_constructor -from biocompute.services import BcoDraftSerializer, bco_counter_increment -from biocompute.selectors import retrieve_bco +from biocompute.services import BcoDraftSerializer, bco_counter_increment, ModifyBcoDraftSerializer +from biocompute.selectors import retrieve_bco, user_can_modify_bco from prefix.selectors import user_can_draft hostname = settings.PUBLIC_HOSTNAME @@ -66,11 +66,10 @@ class DraftsCreateApi(APIView): """ permission_classes = [IsAuthenticated,] - request_body = BCO_DRAFT_SCHEMA @swagger_auto_schema( operation_id="api_objects_drafts_create", - request_body=request_body, + request_body=BCO_DRAFT_SCHEMA, responses={ 200: "All requests were accepted.", 207: "Some requests failed and some succeeded. Each object submitted" @@ -166,6 +165,138 @@ def post(self, request) -> Response: data=response_data ) +class DraftsModifyApi(APIView): + """Modify BCO Draft [Bulk Enabled] + + API endpoint for modifying BioCompute Object (BCO) drafts, with support + for bulk operations. + + This endpoint allows authenticated users to modify existing BCO drafts + individually or in bulk by submitting a list of BCO drafts. The operation + can be performed for one or more drafts in a single request. Each draft is + validated and processed independently, allowing for mixed response + statuses (HTTP_207_MULTI_STATUS) in the case of bulk submissions. + """ + + permission_classes = [IsAuthenticated,] + + @swagger_auto_schema( + operation_id="api_objects_drafts_modify", + request_body=openapi.Schema( + type=openapi.TYPE_ARRAY, + title="Modify BCO Draft Schema", + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=[], + properties={ + "authorized_users": openapi.Schema( + type=openapi.TYPE_ARRAY, + description="Users which can access the BCO draft.", + items=openapi.Schema(type=openapi.TYPE_STRING, example="tester") + ), + "contents": openapi.Schema( + type=openapi.TYPE_OBJECT, + description="Contents of the BCO.", + example=BCO_000001 + ), + }, + ), + description="BCO Drafts to create.", + ), + responses={ + 200: "All requests were accepted.", + 207: "Some requests failed and some succeeded. Each object submitted" + " will have it's own response object with it's own status" + " code and message.\n", + 400: "All requests were rejected.", + 403: "Invalid token.", + }, + tags=["BCO Management"], + ) + + def post(self, request) -> Response: + response_data = [] + requester = request.user + data = request.data + rejected_requests = False + accepted_requests = False + if 'POST_api_objects_drafts_modify' in request.data: + data = legacy_api_converter(request.data) + + for index, object in enumerate(data): + response_id = object.get("object_id", index) + modify_permitted = user_can_modify_bco(response_id, requester) + + if modify_permitted is None: + response_data.append(response_constructor( + identifier=response_id, + status = "NOT FOUND", + code= 404, + message= f"Invalid BCO: {response_id}.", + )) + rejected_requests = True + continue + + if modify_permitted is False: + response_data.append(response_constructor( + identifier=response_id, + status = "FORBIDDEN", + code= 400, + message= f"User, {requester}, does not have draft permissions"\ + + f" for BCO {response_id}.", + )) + rejected_requests = True + continue + + bco = ModifyBcoDraftSerializer(data=object) + + if bco.is_valid(): + try: + bco.update(bco.validated_data) + response_data.append(response_constructor( + identifier=response_id, + status = "SUCCESS", + code= 200, + message= f"BCO {response_id} updated", + )) + accepted_requests = True + + except Exception as err: + response_data.append(response_constructor( + identifier=response_id, + status = "SERVER ERROR", + code= 500, + message= f"BCO {response_id} failed", + )) + + else: + response_data.append(response_constructor( + identifier=response_id, + status = "REJECTED", + code= 400, + message= f"BCO {response_id} rejected", + data=bco.errors + )) + rejected_requests = True + + if accepted_requests is False and rejected_requests == True: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data=response_data + ) + + if accepted_requests is True and rejected_requests is True: + return Response( + status=status.HTTP_207_MULTI_STATUS, + data=response_data + ) + + if accepted_requests is True and rejected_requests is False: + return Response( + status=status.HTTP_200_OK, + data=response_data + ) + class DraftRetrieveApi(APIView): """Get a draft object diff --git a/biocompute/selectors.py b/biocompute/selectors.py index 49cfa800..95147f5e 100644 --- a/biocompute/selectors.py +++ b/biocompute/selectors.py @@ -8,8 +8,26 @@ from django.conf import settings from django.contrib.auth. models import User from biocompute.models import Bco -from prefix.selectors import user_can_view +from prefix.selectors import user_can_view, user_can_modify +def user_can_modify_bco(object_id: str, user:User) -> bool: + """Modify BCO + """ + + try: + bco_instance = Bco.objects.get(object_id=object_id) + except Bco.DoesNotExist: + return None + if user in bco_instance.authorized_users.all(): + return True + + prefix_name = object_id.split("/")[-2].split("_")[0] + view_permission = user_can_modify(prefix_name, user) + if view_permission is False: + return False + + return True + def retrieve_bco(bco_accession:str, user:User, bco_version:str=None) -> bool: """Retrieve BCO @@ -17,11 +35,10 @@ def retrieve_bco(bco_accession:str, user:User, bco_version:str=None) -> bool: identified by its accession number and, optionally, its version. It performs several checks: - 1. Checks if the user has general 'view' permissions for the prefix + 1. Verifies if the BCO exists. If not, returns `None`. + 2. Checks if the user is explicitly authorized to view this specific BCO. + 3. Checks if the user has general 'view' permissions for the prefix associated with the BCO. - 2. Verifies if the BCO exists. If not, returns `None`. - 3. Checks if the user is explicitly authorized to view this specific BCO. - """ hostname = settings.PUBLIC_HOSTNAME @@ -31,11 +48,6 @@ def retrieve_bco(bco_accession:str, user:User, bco_version:str=None) -> bool: else: object_id = f"{hostname}/{bco_accession}/{bco_version}" - prefix_name = bco_accession.split("_")[0] - view_permission = user_can_view(prefix_name, user) - if view_permission is False: - return False - try: bco_instance = Bco.objects.get(object_id=object_id) except Bco.DoesNotExist: @@ -44,4 +56,9 @@ def retrieve_bco(bco_accession:str, user:User, bco_version:str=None) -> bool: if user in bco_instance.authorized_users.all(): return bco_instance + prefix_name = bco_accession.split("_")[0] + view_permission = user_can_view(prefix_name, user) + if view_permission is False: + return False + return bco_instance \ No newline at end of file diff --git a/biocompute/services.py b/biocompute/services.py index 92f9cbe4..4692417e 100644 --- a/biocompute/services.py +++ b/biocompute/services.py @@ -1,7 +1,10 @@ #!/usr/bin/env python3 # biocopmute/services.py +import json +from hashlib import sha256 from biocompute.models import Bco +from copy import deepcopy from django.conf import settings from django.contrib.auth.models import User from django.db import transaction @@ -18,6 +21,78 @@ HOSTNAME = settings.PUBLIC_HOSTNAME +class ModifyBcoDraftSerializer(serializers.Serializer): + """Serializer for modifying draft BioCompute Objects (BCO). + + This serializer is used to validate and serialize data related to the + update of BCO drafts. + + Attributes: + - contents (JSONField): + The contents of the BCO in JSON format. + - authorized_users (ListField): + A list of usernames authorized to access the BCO, besides the owner. + + Methods: + - validate: Validates the incoming data for updating a BCO draft. + - update: Updates a BCO instance based on the validated data. + """ + contents = serializers.JSONField() + authorized_users = serializers.ListField(child=serializers.CharField(), required=False) + + def validate(self, attrs): + """BCO Modify Draft Validator + + Parameters: + - attrs (dict): + The incoming data to be validated. + + Returns: + - dict: + The validated data. + + Raises: + - serializers.ValidationError: If any validation checks fail. + """ + + errors = {} + request = self.context.get('request') + + if 'authorized_users' in attrs: + for user in attrs['authorized_users']: + try: + User.objects.get(username=user) + except Exception as err: + errors['authorized_users'] =f"Invalid user: {user}" + + if errors: + raise serializers.ValidationError(errors) + + return attrs + + @transaction.atomic + def update(self, validated_data): + """ + """ + + authorized_usernames = validated_data.pop('authorized_users', []) + bco_instance = Bco.objects.get( + object_id = validated_data['contents']['object_id'] + ) + bco_instance.contents = validated_data['contents'] + bco_instance.last_update=timezone.now() + bco_contents = deepcopy(bco_instance.contents) + etag = generate_etag(bco_contents) + bco_instance.contents['etag'] = etag + bco_instance.save() + if authorized_usernames: + authorized_users = User.objects.filter( + username__in=authorized_usernames + ) + bco_instance.authorized_users.set(authorized_users) + + return bco_instance + class BcoDraftSerializer(serializers.Serializer): """Serializer for drafting BioCompute Objects (BCO). @@ -102,6 +177,7 @@ def create(self, validated_data): """Creates a new BCO instance based on the validated data. If 'object_id' is not provided in the validated data, it generates one. + The `etag` is then generated after the BCO is created. It also handles the creation of the BCO instance and setting up the many-to-many relationships for 'authorized_users'. @@ -115,12 +191,18 @@ def create(self, validated_data): validated_data.pop('prefix_name') authorized_usernames = validated_data.pop('authorized_users', []) if 'object_id' not in validated_data: - validated_data['object_id'] = create_bco_id( - validated_data['prefix'] - ) + object_id = create_bco_id(validated_data['prefix']) + validated_data['object_id'] = object_id + validated_data['contents']['object_id'] = object_id + + bco_instance = Bco.objects.create( **validated_data, last_update=timezone.now() ) + bco_contents = deepcopy(bco_instance.contents) + etag = generate_etag(bco_contents) + bco_instance.contents['etag'] = etag + bco_instance.save() if authorized_usernames: authorized_users = User.objects.filter( @@ -130,7 +212,6 @@ def create(self, validated_data): return bco_instance - def validate_bco_object_id(object_id: str, prefix_name: str): """Validate BCO object ID @@ -181,8 +262,19 @@ def create_bco_id(prefix_instance: Prefix) -> str: def bco_counter_increment(bco_instance: Bco) -> int: """BCO Counter Increment - Simple incrementing function. - Counter for BCO object_id asignment. + Increments the access count for a BioCompute Object (BCO). + + This function is designed to track the number of times a specific BCO has + been accessed or viewed. It increments the `access_count` field of the + provided BCO instance by one and saves the update to the database. + + Parameters: + - bco_instance (Bco): + An instance of the BCO model whose access count is to be incremented. + + Returns: + - int: + The updated access count of the BCO instance after incrementing. """ bco_instance.access_count = F('access_count') + 1 @@ -190,4 +282,29 @@ def bco_counter_increment(bco_instance: Bco) -> int: bco_instance.refresh_from_db() - return bco_instance.access_count \ No newline at end of file + return bco_instance.access_count + +def generate_etag(bco_contents: dict) -> str: + """Genreate ETag + + Generates a SHA-256 hash etag for a BioCompute Object (BCO). + + The etag serves as a string-type, read-only value that protects the BCO + from internal or external alterations without proper validation. It is + generated by hashing the contents of the BCO using the SHA-256 hash + function. To ensure the integrity and uniqueness of the etag, the + 'object_id', 'spec_version', and 'etag' fields are excluded from the hash + generation process. + + Parameters: + - bco_contents (dict): + The contents of the BCO, from which the etag will be generated. + + Returns: + - str: + A SHA-256 hash string acting as the etag for the BCO. + """ + + del bco_contents['object_id'], bco_contents['spec_version'], bco_contents['etag'] + bco_etag = sha256(json.dumps(bco_contents).encode('utf-8')).hexdigest() + return bco_etag diff --git a/biocompute/urls.py b/biocompute/urls.py index f7249d83..aa2fc7cc 100644 --- a/biocompute/urls.py +++ b/biocompute/urls.py @@ -4,10 +4,11 @@ from django.urls import path from biocompute.apis import ( - DraftsCreateApi + DraftsCreateApi, + DraftsModifyApi, ) urlpatterns = [ path("objects/drafts/create/", DraftsCreateApi.as_view()), - + path("objects/drafts/modify/", DraftsModifyApi.as_view()), ] \ No newline at end of file diff --git a/prefix/selectors.py b/prefix/selectors.py index f94378f1..dc27a76f 100644 --- a/prefix/selectors.py +++ b/prefix/selectors.py @@ -10,6 +10,23 @@ from django.db import utils from prefix.models import Prefix +def user_can_modify(user: User, prefix_name:str) -> bool: + """User Can Modify + + Takes a prefix name and user. Returns a bool if the user can modify a BCO + with the prefix if it exists. If the prefix does not exist `None` is + returned. + """ + + try: + Prefix.objects.get(prefix=prefix_name) + except Prefix.DoesNotExist: + return None + codename = f"change_{prefix_name}" + user_prefixes = get_user_prefixes(user) + + return codename in user_prefixes + def user_can_draft(user: User, prefix_name:str) -> bool: """User Can Draft diff --git a/tests/test_apis/test_biocompute/objects_drafts_create.py b/tests/test_apis/test_biocompute/objects_drafts_create.py deleted file mode 100644 index 68b15fc1..00000000 --- a/tests/test_apis/test_biocompute/objects_drafts_create.py +++ /dev/null @@ -1,135 +0,0 @@ - -#!/usr/bin/env python3 - -"""Objects/Drafts_create -Tests for 'Creation of BCO draft is successful.' (200), -returns 207, 403 (needs to be reviewed) -""" - - -import json -from django.test import TestCase -from django.contrib.auth.models import User -from rest_framework.authtoken.models import Token -from rest_framework.test import APIClient - -class BcoDraftCreateTestCase(TestCase): - fixtures = ['tests/fixtures/test_data'] - def setUp(self): - self.client = APIClient() - - self.token = Token.objects.get(user=User.objects.get(username="tester")) - - self.legacy_data = { - "POST_api_objects_draft_create": [ - { - "prefix": "BCO", - "owner_group": "tester", - "object_id": "http://127.0.0.1:8000/BCO_000002/DRAFT", - "schema": "IEEE", - "contents": { - "object_id": "https://test.portal.biochemistry.gwu.edu/BCO_000001/DRAFT", - "spec_version": "https://w3id.org/ieee/ieee-2791-schema/2791object.json", - "etag": "11ee4c3b8a04ad16dcca19a6f478c0870d3fe668ed6454096ab7165deb1ab8ea" - } - } - ] - } - - self.data = [ - { - "object_id": "http://127.0.0.1:8000/BCO_000001/DRAFT", - "prefix": "BCO", - "authorized_users": ["hivelab"], - "contents": { - "object_id": "https://test.portal.biochemistry.gwu.edu/BCO_000001/DRAFT", - "spec_version": "https://w3id.org/ieee/ieee-2791-schema/2791object.json", - "etag": "11ee4c3b8a04ad16dcca19a6f478c0870d3fe668ed6454096ab7165deb1ab8ea" - } - }, - { - "object_id": "http://127.0.0.1:8000/TEST_000001", - "prefix": "TEST", - "contents": { - "object_id": "https://biocomputeobject.org/TEST_000001", - "spec_version": "https://w3id.org/ieee/ieee-2791-schema/2791object.json", - "etag": "11ee4c3b8a04ad16dcca19a6f478c0870d3fe668ed6454096ab7165deb1ab8ea" - } - } - ] - - def test_legacy_successful_creation(self): - """200: Creation of BCO drafts is successful. - """ - - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) - response = self.client.post('/api/objects/drafts/create/', self.legacy_data, format='json') - self.assertEqual(response.status_code, 200) - - def test_successful_creation(self): - """200: Creation of BCO drafts is successful. - """ - - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) - response = self.client.post('/api/objects/drafts/create/', self.data, format='json') - self.assertEqual(response.status_code, 200) - - def test_partial_failure(self): - # Test case for partial failure (response code 300) - ##Returns 207(Multi status) instead of 300(Partial faliure) - data = { - 'POST_api_objects_draft_create': [ - { - 'prefix': 'BCO', - 'owner_group': 'bco_drafter', - 'schema': 'IEEE', - 'contents': {} - }, - { - 'prefix': 'Reeyaa', - 'owner_group': 'bco_drafter', - 'schema': 'IEEE', - 'contents': {} - } - ] - } - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) - response = self.client.post('/api/objects/drafts/create/', data=data, format='json') - self.assertEqual(response.status_code, 207) - - def test_bad_request(self): - # Test case for bad request (response code 400) - #Gives 403 forbidden request instead of 400 - data = [ - { - "object_id": "http://127.0.0.1:8000/TEST_000001", - "prefix": "TEST", - "contents": { - "object_id": "https://biocomputeobject.org/TEST_000001", - "spec_version": "https://w3id.org/ieee/ieee-2791-schema/2791object.json", - "etag": "11ee4c3b8a04ad16dcca19a6f478c0870d3fe668ed6454096ab7165deb1ab8ea" - } - } - ] - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) - response = self.client.post('/api/objects/drafts/create/', data=data, format='json') - self.assertEqual(response.status_code, 400) - - def test_invalid_token(self): - # Test case for invalid token (response code 403) - # Setting authentication token to an invalid value - - data = { - 'POST_api_objects_draft_create': [ - { - 'prefix': 'BCO', - 'owner_group': 'bco_drafter', - 'schema': 'IEEE', - 'contents': {} - }, - - ] - } - self.client.credentials(HTTP_AUTHORIZATION='Token InvalidToken') - response = self.client.post('/api/objects/drafts/create/', data=data, format='json') - self.assertEqual(response.status_code, 403) diff --git a/tests/test_apis/test_biocompute/test_objects_drafts_create.py b/tests/test_apis/test_biocompute/test_objects_drafts_create.py deleted file mode 100644 index 68b15fc1..00000000 --- a/tests/test_apis/test_biocompute/test_objects_drafts_create.py +++ /dev/null @@ -1,135 +0,0 @@ - -#!/usr/bin/env python3 - -"""Objects/Drafts_create -Tests for 'Creation of BCO draft is successful.' (200), -returns 207, 403 (needs to be reviewed) -""" - - -import json -from django.test import TestCase -from django.contrib.auth.models import User -from rest_framework.authtoken.models import Token -from rest_framework.test import APIClient - -class BcoDraftCreateTestCase(TestCase): - fixtures = ['tests/fixtures/test_data'] - def setUp(self): - self.client = APIClient() - - self.token = Token.objects.get(user=User.objects.get(username="tester")) - - self.legacy_data = { - "POST_api_objects_draft_create": [ - { - "prefix": "BCO", - "owner_group": "tester", - "object_id": "http://127.0.0.1:8000/BCO_000002/DRAFT", - "schema": "IEEE", - "contents": { - "object_id": "https://test.portal.biochemistry.gwu.edu/BCO_000001/DRAFT", - "spec_version": "https://w3id.org/ieee/ieee-2791-schema/2791object.json", - "etag": "11ee4c3b8a04ad16dcca19a6f478c0870d3fe668ed6454096ab7165deb1ab8ea" - } - } - ] - } - - self.data = [ - { - "object_id": "http://127.0.0.1:8000/BCO_000001/DRAFT", - "prefix": "BCO", - "authorized_users": ["hivelab"], - "contents": { - "object_id": "https://test.portal.biochemistry.gwu.edu/BCO_000001/DRAFT", - "spec_version": "https://w3id.org/ieee/ieee-2791-schema/2791object.json", - "etag": "11ee4c3b8a04ad16dcca19a6f478c0870d3fe668ed6454096ab7165deb1ab8ea" - } - }, - { - "object_id": "http://127.0.0.1:8000/TEST_000001", - "prefix": "TEST", - "contents": { - "object_id": "https://biocomputeobject.org/TEST_000001", - "spec_version": "https://w3id.org/ieee/ieee-2791-schema/2791object.json", - "etag": "11ee4c3b8a04ad16dcca19a6f478c0870d3fe668ed6454096ab7165deb1ab8ea" - } - } - ] - - def test_legacy_successful_creation(self): - """200: Creation of BCO drafts is successful. - """ - - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) - response = self.client.post('/api/objects/drafts/create/', self.legacy_data, format='json') - self.assertEqual(response.status_code, 200) - - def test_successful_creation(self): - """200: Creation of BCO drafts is successful. - """ - - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) - response = self.client.post('/api/objects/drafts/create/', self.data, format='json') - self.assertEqual(response.status_code, 200) - - def test_partial_failure(self): - # Test case for partial failure (response code 300) - ##Returns 207(Multi status) instead of 300(Partial faliure) - data = { - 'POST_api_objects_draft_create': [ - { - 'prefix': 'BCO', - 'owner_group': 'bco_drafter', - 'schema': 'IEEE', - 'contents': {} - }, - { - 'prefix': 'Reeyaa', - 'owner_group': 'bco_drafter', - 'schema': 'IEEE', - 'contents': {} - } - ] - } - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) - response = self.client.post('/api/objects/drafts/create/', data=data, format='json') - self.assertEqual(response.status_code, 207) - - def test_bad_request(self): - # Test case for bad request (response code 400) - #Gives 403 forbidden request instead of 400 - data = [ - { - "object_id": "http://127.0.0.1:8000/TEST_000001", - "prefix": "TEST", - "contents": { - "object_id": "https://biocomputeobject.org/TEST_000001", - "spec_version": "https://w3id.org/ieee/ieee-2791-schema/2791object.json", - "etag": "11ee4c3b8a04ad16dcca19a6f478c0870d3fe668ed6454096ab7165deb1ab8ea" - } - } - ] - self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) - response = self.client.post('/api/objects/drafts/create/', data=data, format='json') - self.assertEqual(response.status_code, 400) - - def test_invalid_token(self): - # Test case for invalid token (response code 403) - # Setting authentication token to an invalid value - - data = { - 'POST_api_objects_draft_create': [ - { - 'prefix': 'BCO', - 'owner_group': 'bco_drafter', - 'schema': 'IEEE', - 'contents': {} - }, - - ] - } - self.client.credentials(HTTP_AUTHORIZATION='Token InvalidToken') - response = self.client.post('/api/objects/drafts/create/', data=data, format='json') - self.assertEqual(response.status_code, 403)