diff --git a/api/base/generic_bulk_views.py b/api/base/generic_bulk_views.py index 8e1c31728a3..eb96654ae2f 100644 --- a/api/base/generic_bulk_views.py +++ b/api/base/generic_bulk_views.py @@ -131,7 +131,7 @@ def bulk_destroy(self, request, *args, **kwargs): data = request.data num_items = len(data) - bulk_limit = BULK_SETTINGS['DEFAULT_BULK_LIMIT'] + bulk_limit = getattr(self, 'bulk_limit', BULK_SETTINGS['DEFAULT_BULK_LIMIT']) if num_items > bulk_limit: raise JSONAPIException( diff --git a/api/base/serializers.py b/api/base/serializers.py index d5b2c15a95e..dc2384c2a0d 100644 --- a/api/base/serializers.py +++ b/api/base/serializers.py @@ -1391,7 +1391,16 @@ def update(self, instance, validated_data): # overrides ListSerializer def run_validation(self, data): meta = getattr(self, 'Meta', None) - bulk_limit = getattr(meta, 'bulk_limit', BULK_SETTINGS['DEFAULT_BULK_LIMIT']) + + if meta and hasattr(meta, 'bulk_limit'): + bulk_limit = meta.bulk_limit + else: + child_meta = getattr(getattr(self, 'child', None), 'Meta', None) + bulk_limit = getattr( + child_meta, + 'bulk_limit', + BULK_SETTINGS['DEFAULT_BULK_LIMIT'], + ) num_items = len(data) diff --git a/api/base/settings/defaults.py b/api/base/settings/defaults.py index 215a2552dfe..148f4ca7562 100644 --- a/api/base/settings/defaults.py +++ b/api/base/settings/defaults.py @@ -127,6 +127,7 @@ BULK_SETTINGS = { 'DEFAULT_BULK_LIMIT': 100, + 'NODE_CONTRIBUTORS_BULK_LIMIT': 50, } MAX_PAGE_SIZE = 100 @@ -189,6 +190,7 @@ 'user': '10000/day', 'non-cookie-auth': '100/hour', 'add-contributor': '10/second', + 'add-contributor-unregistered': '30/hour', 'create-guid': '1000/hour', 'root-anon-throttle': '1000/hour', 'test-user': '2/hour', diff --git a/api/base/settings/local-ci.py b/api/base/settings/local-ci.py index 5dc5d6035f4..052e014ecb6 100644 --- a/api/base/settings/local-ci.py +++ b/api/base/settings/local-ci.py @@ -13,6 +13,7 @@ 'user': '1000000/second', 'non-cookie-auth': '1000000/second', 'add-contributor': '1000000/second', + 'add-contributor-unregistered': '1000000/second', 'create-guid': '1000000/second', 'root-anon-throttle': '1000000/second', 'test-user': '2/hour', diff --git a/api/base/settings/local-dist.py b/api/base/settings/local-dist.py index 637ec81646b..0297bfe1882 100644 --- a/api/base/settings/local-dist.py +++ b/api/base/settings/local-dist.py @@ -36,6 +36,7 @@ 'user': '1000000/second', 'non-cookie-auth': '1000000/second', 'add-contributor': '1000000/second', + 'add-contributor-unregistered': '1000000/second', 'create-guid': '1000000/second', 'root-anon-throttle': '1000000/second', 'test-user': '2/hour', diff --git a/api/base/throttling.py b/api/base/throttling.py index e4e97d8c1ad..345805454e7 100644 --- a/api/base/throttling.py +++ b/api/base/throttling.py @@ -69,6 +69,27 @@ def allow_request(self, request, view): return super().allow_request(request, view) +class AddContributorUnregisteredThrottle(BaseThrottle, UserRateThrottle): + + scope = 'add-contributor-unregistered' + + def allow_request(self, request, view): + """ + Allow all add contributor requests that do not contain unregistered contributors. + """ + if request.method == 'POST': + data = request.data + items = data if isinstance(data, list) else [data] + contains_unregistered_contributor = any( + isinstance(item, dict) and item.get('email') and not item.get('id') + for item in items + ) + if not contains_unregistered_contributor: + return True + + return super().allow_request(request, view) + + class CreateGuidThrottle(BaseThrottle, UserRateThrottle): scope = 'create-guid' diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index 1e68d1ff2e4..49de070cff2 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -45,6 +45,7 @@ from website.project import new_private_link from website.project.model import NodeUpdateError from osf.utils import permissions as osf_permissions +from api.base.settings import BULK_SETTINGS class RegistrationProviderRelationshipField(RelationshipField): @@ -1222,6 +1223,8 @@ def get_unregistered_contributor(self, obj): class NodeContributorsBulkCreateListSerializer(JSONAPIListSerializer): + class Meta: + bulk_limit = BULK_SETTINGS['NODE_CONTRIBUTORS_BULK_LIMIT'] email_preferences = ['false'] @@ -1313,6 +1316,7 @@ class NodeContributorsCreateSerializer(NodeContributorsSerializer): class Meta(NodeContributorsSerializer.Meta): list_serializer_class = NodeContributorsBulkCreateListSerializer + bulk_limit = BULK_SETTINGS['NODE_CONTRIBUTORS_BULK_LIMIT'] def validate_data(self, resource, user_id=None, full_name=None, email=None, index=None, child_nodes=None): if not user_id and not full_name: @@ -1363,6 +1367,9 @@ class NodeContributorDetailSerializer(NodeContributorsSerializer): id = IDField(required=True, source='_id') index = ser.IntegerField(required=False, read_only=False, source='_order') + class Meta(NodeContributorsSerializer.Meta): + bulk_limit = BULK_SETTINGS['NODE_CONTRIBUTORS_BULK_LIMIT'] + def update(self, instance, validated_data): return update_contributors_permissions_and_bibliographic_status( self, diff --git a/api/nodes/views.py b/api/nodes/views.py index 931220a6f88..a38dc410d5c 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -5,6 +5,7 @@ import dataclasses import waffle +from api.base.settings import BULK_SETTINGS from api.collections.serializers import CollectionSerializer from osf import features from packaging.version import Version @@ -45,6 +46,7 @@ UserRateThrottle, NonCookieAuthThrottle, AddContributorThrottle, + AddContributorUnregisteredThrottle, BurstRateThrottle, FilesRateThrottle, FilesBurstRateThrottle, @@ -432,7 +434,7 @@ class NodeContributorsList(BaseContributorList, bulk_views.BulkUpdateJSONAPIView required_write_scopes = [CoreScopes.NODE_CONTRIBUTORS_WRITE] model_class = OSFUser - throttle_classes = (AddContributorThrottle, UserRateThrottle, NonCookieAuthThrottle, BurstRateThrottle) + throttle_classes = (AddContributorThrottle, UserRateThrottle, NonCookieAuthThrottle, BurstRateThrottle, AddContributorUnregisteredThrottle) pagination_class = NodeContributorPagination serializer_class = NodeContributorsSerializer @@ -440,6 +442,8 @@ class NodeContributorsList(BaseContributorList, bulk_views.BulkUpdateJSONAPIView view_name = 'node-contributors' ordering = ('_order',) # default ordering + bulk_limit = BULK_SETTINGS['NODE_CONTRIBUTORS_BULK_LIMIT'] + def get_resource(self): return self.get_node() diff --git a/api_tests/nodes/views/test_node_contributors_list.py b/api_tests/nodes/views/test_node_contributors_list.py index 854f5288a14..c73d91d2d73 100644 --- a/api_tests/nodes/views/test_node_contributors_list.py +++ b/api_tests/nodes/views/test_node_contributors_list.py @@ -1589,7 +1589,7 @@ def test_node_contributor_bulk_create_payload_errors( bulk=True, ) assert ( - res.json['errors'][0]['detail'] == 'Bulk operation limit is 100, got 101.' + res.json['errors'][0]['detail'] == 'Bulk operation limit is 50, got 101.' ) assert res.json['errors'][0]['source']['pointer'] == '/data' @@ -1923,7 +1923,7 @@ def test_bulk_update_contributors_errors( bulk=True, ) assert ( - res.json['errors'][0]['detail'] == 'Bulk operation limit is 100, got 101.' + res.json['errors'][0]['detail'] == 'Bulk operation limit is 50, got 101.' ) assert res.json['errors'][0]['source']['pointer'] == '/data' @@ -2353,7 +2353,7 @@ def test_bulk_partial_update_errors( bulk=True, ) assert ( - res.json['errors'][0]['detail'] == 'Bulk operation limit is 100, got 101.' + res.json['errors'][0]['detail'] == 'Bulk operation limit is 50, got 101.' ) assert res.json['errors'][0]['source']['pointer'] == '/data' @@ -2765,7 +2765,7 @@ def test_bulk_delete_contributors_errors( ) assert res.status_code == 400 assert ( - res.json['errors'][0]['detail'] == 'Bulk operation limit is 100, got 101.' + res.json['errors'][0]['detail'] == 'Bulk operation limit is 50, got 101.' ) assert res.json['errors'][0]['source']['pointer'] == '/data' diff --git a/api_tests/preprints/views/test_preprint_contributors_list.py b/api_tests/preprints/views/test_preprint_contributors_list.py index f753245bdd8..58cdf18e5a5 100644 --- a/api_tests/preprints/views/test_preprint_contributors_list.py +++ b/api_tests/preprints/views/test_preprint_contributors_list.py @@ -1755,7 +1755,7 @@ def test_preprint_contributor_bulk_create_payload_errors( node_contrib_create_list = {'data': [payload_one] * 101} res = app.post_json_api(url_published, node_contrib_create_list, auth=user.auth, expect_errors=True, bulk=True) - assert res.json['errors'][0]['detail'] == 'Bulk operation limit is 100, got 101.' + assert res.json['errors'][0]['detail'] == 'Bulk operation limit is 50, got 101.' assert res.json['errors'][0]['source']['pointer'] == '/data' # test_preprint_contributor_ugly_payload @@ -2092,7 +2092,7 @@ def test_bulk_update_contributors_errors( res = app.put_json_api( url_published, contrib_update_list, auth=user.auth, expect_errors=True, bulk=True) - assert res.json['errors'][0]['detail'] == 'Bulk operation limit is 100, got 101.' + assert res.json['errors'][0]['detail'] == 'Bulk operation limit is 50, got 101.' assert res.json['errors'][0]['source']['pointer'] == '/data' # test_bulk_update_contributors_invalid_permissions @@ -2514,7 +2514,7 @@ def test_bulk_partial_update_errors( res = app.patch_json_api( url_published, contrib_update_list, auth=user.auth, expect_errors=True, bulk=True) - assert res.json['errors'][0]['detail'] == 'Bulk operation limit is 100, got 101.' + assert res.json['errors'][0]['detail'] == 'Bulk operation limit is 50, got 101.' assert res.json['errors'][0]['source']['pointer'] == '/data' # test_bulk_partial_update_invalid_permissions @@ -2879,7 +2879,7 @@ def test_bulk_delete_contributors_errors( url_published, new_payload, auth=user.auth, expect_errors=True, bulk=True) assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'Bulk operation limit is 100, got 101.' + assert res.json['errors'][0]['detail'] == 'Bulk operation limit is 50, got 101.' assert res.json['errors'][0]['source']['pointer'] == '/data' # test_bulk_delete_contributors_no_payload