Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions api/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from osf import features
from packaging.version import Version
from django.apps import apps
from django.db.models import F, Max, Q, Subquery
from django.db.models import F, Max, Q, Subquery, Exists, OuterRef
from django.db import transaction
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from rest_framework import generics, permissions as drf_permissions, exceptions
Expand Down Expand Up @@ -154,6 +155,7 @@
Folder,
CedarMetadataRecord,
Preprint, Collection,
Contributor,
)
from addons.osfstorage.models import Region
from osf.utils.permissions import ADMIN, WRITE_NODE
Expand Down Expand Up @@ -549,13 +551,28 @@ def perform_destroy(self, instance):
auth = get_user_auth(self.request)
if node.visible_contributors.count() == 1 and instance.visible:
raise ValidationError('Must have at least one visible contributor')
removed = node.remove_contributor(instance, auth)
if not removed:
raise ValidationError('Must have at least one registered admin contributor')
propagate = self.request.query_params.get('propagate_to_children') == 'true'
if propagate:
for child_node in node.get_nodes(_contributors__in=[instance.user]):
child_node.remove_contributor(instance, auth)

include_children = is_truthy(self.request.query_params.get('include_children', False))

if include_children and isinstance(node, Node):
hierarchy = Node.objects.get_children(node, active=True, include_root=True)
targets = hierarchy.filter(
Exists(
Contributor.objects.filter(
node=OuterRef('pk'),
user=instance.user,
),
),
)
with transaction.atomic():
for descendant in targets:
removed = descendant.remove_contributor(instance, auth)
if not removed:
raise ValidationError(f'{descendant._id} must have at least one registered admin contributor')
else:
removed = node.remove_contributor(instance, auth)
if not removed:
raise ValidationError('Must have at least one registered admin contributor')


class NodeImplicitContributorsList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin, NodeMixin):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
)
from api_tests.nodes.views.test_node_contributors_detail_ordering import TestNodeContributorOrdering
from api_tests.nodes.views.test_node_contributors_detail_update import TestNodeContributorUpdate
from api_tests.utils import disconnected_from_listeners
from osf_tests.factories import (
DraftRegistrationFactory,
ProjectFactory,
AuthUserFactory
)
from osf.utils import permissions
from website.project.signals import contributor_removed


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

def test_remove_contributor_include_children_removes_descendants(self, app, user, user_write_contrib, project):
assert user_write_contrib in project.contributors

url = f'/{API_BASE}draft_registrations/{project._id}/contributors/{user_write_contrib._id}/?include_children=true'
with disconnected_from_listeners(contributor_removed):
res = app.delete(url, auth=user.auth)
assert res.status_code == 204

project.reload()
assert user_write_contrib not in project.contributors

def test_remove_contributor_include_children_is_atomic_on_violation(self, app, user, user_write_contrib, project):
assert user_write_contrib in project.contributors

# Draft registrations don't have children, so include_children parameter is ignored
# The contributor should be removed successfully since there are no children to cause violations
url = f'/{API_BASE}draft_registrations/{project._id}/contributors/{user_write_contrib._id}/?include_children=true'
with disconnected_from_listeners(contributor_removed):
res = app.delete(url, auth=user.auth)
assert res.status_code == 204

project.reload()
assert user_write_contrib not in project.contributors


@pytest.mark.django_db
class TestDraftBibliographicContributorDetail():
Expand Down
43 changes: 43 additions & 0 deletions api_tests/nodes/views/test_node_contributors_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,46 @@ def test_can_remove_self_as_contributor_not_unique_admin(self, app, user_write_c
)
assert res.status_code == 204
assert user_write_contrib not in project.contributors

def test_remove_contributor_include_children_removes_descendants(self, app, user, user_write_contrib, project):
child1 = ProjectFactory(parent=project, creator=user)
child2 = ProjectFactory(parent=project, creator=user)
child1.add_contributor(user_write_contrib, permissions=permissions.WRITE, visible=True, save=True)
child2.add_contributor(user_write_contrib, permissions=permissions.WRITE, visible=True, save=True)

assert user_write_contrib in project.contributors
assert user_write_contrib in child1.contributors
assert user_write_contrib in child2.contributors

url = f'/{API_BASE}nodes/{project._id}/contributors/{user_write_contrib._id}/?include_children=true'
with disconnected_from_listeners(contributor_removed):
res = app.delete(url, auth=user.auth)
assert res.status_code == 204

project.reload()
child1.reload()
child2.reload()

assert user_write_contrib not in project.contributors
assert user_write_contrib not in child1.contributors
assert user_write_contrib not in child2.contributors

def test_remove_contributor_include_children_is_atomic_on_violation(self, app, user, user_write_contrib, project):
child = ProjectFactory(parent=project, creator=user)
child.add_contributor(user_write_contrib, permissions=permissions.ADMIN, visible=True, save=True)
child.set_permissions(user, permissions.READ, save=True)

assert user_write_contrib in project.contributors
assert user_write_contrib in child.contributors

url = f'/{API_BASE}nodes/{project._id}/contributors/{user_write_contrib._id}/?include_children=true'
with disconnected_from_listeners(contributor_removed):
res = app.delete(url, auth=user.auth, expect_errors=True)

assert res.status_code == 400

project.reload()
child.reload()

assert user_write_contrib in project.contributors
assert user_write_contrib in child.contributors
Loading