Skip to content

Commit e8404e8

Browse files
antkrytadlius
authored andcommitted
[ENG-9157] Add atomic ability to remove contributors from children projects in API (CenterForOpenScience#11425)
* add include_children to delete contributors * atomic contributor remove; add tests * remove from children only for nodes * fix tests --------- Co-authored-by: Yuhuai Liu <yuhuai@cos.io>
1 parent 459a5e9 commit e8404e8

3 files changed

Lines changed: 94 additions & 8 deletions

File tree

api/nodes/views.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from osf import features
1010
from packaging.version import Version
1111
from django.apps import apps
12-
from django.db.models import F, Max, Q, Subquery
12+
from django.db.models import F, Max, Q, Subquery, Exists, OuterRef
13+
from django.db import transaction
1314
from django.utils import timezone
1415
from django.contrib.contenttypes.models import ContentType
1516
from rest_framework import generics, permissions as drf_permissions, exceptions
@@ -155,6 +156,7 @@
155156
CedarMetadataRecord,
156157
Preprint,
157158
Collection,
159+
Contributor,
158160
NotificationType,
159161
)
160162
from addons.osfstorage.models import Region
@@ -551,13 +553,28 @@ def perform_destroy(self, instance):
551553
auth = get_user_auth(self.request)
552554
if node.visible_contributors.count() == 1 and instance.visible:
553555
raise ValidationError('Must have at least one visible contributor')
554-
removed = node.remove_contributor(instance, auth)
555-
if not removed:
556-
raise ValidationError('Must have at least one registered admin contributor')
557-
propagate = self.request.query_params.get('propagate_to_children') == 'true'
558-
if propagate:
559-
for child_node in node.get_nodes(_contributors__in=[instance.user]):
560-
child_node.remove_contributor(instance, auth)
556+
557+
include_children = is_truthy(self.request.query_params.get('include_children', False))
558+
559+
if include_children and isinstance(node, Node):
560+
hierarchy = Node.objects.get_children(node, active=True, include_root=True)
561+
targets = hierarchy.filter(
562+
Exists(
563+
Contributor.objects.filter(
564+
node=OuterRef('pk'),
565+
user=instance.user,
566+
),
567+
),
568+
)
569+
with transaction.atomic():
570+
for descendant in targets:
571+
removed = descendant.remove_contributor(instance, auth)
572+
if not removed:
573+
raise ValidationError(f'{descendant._id} must have at least one registered admin contributor')
574+
else:
575+
removed = node.remove_contributor(instance, auth)
576+
if not removed:
577+
raise ValidationError('Must have at least one registered admin contributor')
561578

562579

563580
class NodeImplicitContributorsList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin, NodeMixin):

api_tests/draft_registrations/views/test_draft_registration_contributor_detail.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
)
99
from api_tests.nodes.views.test_node_contributors_detail_ordering import TestNodeContributorOrdering
1010
from api_tests.nodes.views.test_node_contributors_detail_update import TestNodeContributorUpdate
11+
from api_tests.utils import disconnected_from_listeners
1112
from osf_tests.factories import (
1213
DraftRegistrationFactory,
1314
ProjectFactory,
1415
AuthUserFactory
1516
)
1617
from osf.utils import permissions
18+
from website.project.signals import contributor_removed
1719

1820

1921
@pytest.fixture()
@@ -251,6 +253,30 @@ def url_user_non_contrib(self, project, user_non_contrib):
251253
return '/{}draft_registrations/{}/contributors/{}/'.format(
252254
API_BASE, project._id, user_non_contrib._id)
253255

256+
def test_remove_contributor_include_children_removes_descendants(self, app, user, user_write_contrib, project):
257+
assert user_write_contrib in project.contributors
258+
259+
url = f'/{API_BASE}draft_registrations/{project._id}/contributors/{user_write_contrib._id}/?include_children=true'
260+
with disconnected_from_listeners(contributor_removed):
261+
res = app.delete(url, auth=user.auth)
262+
assert res.status_code == 204
263+
264+
project.reload()
265+
assert user_write_contrib not in project.contributors
266+
267+
def test_remove_contributor_include_children_is_atomic_on_violation(self, app, user, user_write_contrib, project):
268+
assert user_write_contrib in project.contributors
269+
270+
# Draft registrations don't have children, so include_children parameter is ignored
271+
# The contributor should be removed successfully since there are no children to cause violations
272+
url = f'/{API_BASE}draft_registrations/{project._id}/contributors/{user_write_contrib._id}/?include_children=true'
273+
with disconnected_from_listeners(contributor_removed):
274+
res = app.delete(url, auth=user.auth)
275+
assert res.status_code == 204
276+
277+
project.reload()
278+
assert user_write_contrib not in project.contributors
279+
254280

255281
@pytest.mark.django_db
256282
class TestDraftBibliographicContributorDetail():

api_tests/nodes/views/test_node_contributors_detail.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,3 +457,46 @@ def test_can_remove_self_as_contributor_not_unique_admin(self, app, user_write_c
457457
)
458458
assert res.status_code == 204
459459
assert user_write_contrib not in project.contributors
460+
461+
def test_remove_contributor_include_children_removes_descendants(self, app, user, user_write_contrib, project):
462+
child1 = ProjectFactory(parent=project, creator=user)
463+
child2 = ProjectFactory(parent=project, creator=user)
464+
child1.add_contributor(user_write_contrib, permissions=permissions.WRITE, visible=True, save=True)
465+
child2.add_contributor(user_write_contrib, permissions=permissions.WRITE, visible=True, save=True)
466+
467+
assert user_write_contrib in project.contributors
468+
assert user_write_contrib in child1.contributors
469+
assert user_write_contrib in child2.contributors
470+
471+
url = f'/{API_BASE}nodes/{project._id}/contributors/{user_write_contrib._id}/?include_children=true'
472+
with disconnected_from_listeners(contributor_removed):
473+
res = app.delete(url, auth=user.auth)
474+
assert res.status_code == 204
475+
476+
project.reload()
477+
child1.reload()
478+
child2.reload()
479+
480+
assert user_write_contrib not in project.contributors
481+
assert user_write_contrib not in child1.contributors
482+
assert user_write_contrib not in child2.contributors
483+
484+
def test_remove_contributor_include_children_is_atomic_on_violation(self, app, user, user_write_contrib, project):
485+
child = ProjectFactory(parent=project, creator=user)
486+
child.add_contributor(user_write_contrib, permissions=permissions.ADMIN, visible=True, save=True)
487+
child.set_permissions(user, permissions.READ, save=True)
488+
489+
assert user_write_contrib in project.contributors
490+
assert user_write_contrib in child.contributors
491+
492+
url = f'/{API_BASE}nodes/{project._id}/contributors/{user_write_contrib._id}/?include_children=true'
493+
with disconnected_from_listeners(contributor_removed):
494+
res = app.delete(url, auth=user.auth, expect_errors=True)
495+
496+
assert res.status_code == 400
497+
498+
project.reload()
499+
child.reload()
500+
501+
assert user_write_contrib in project.contributors
502+
assert user_write_contrib in child.contributors

0 commit comments

Comments
 (0)