diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c61d2fc2..d89151c9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -35,6 +35,7 @@ Added - ``SODARUserSerializer`` ``auth_type`` field (#1501) - ``UserRetrieveAPIView`` REST API view (#1555) - ``active`` arg in ``ProjectInviteMixin.make_invite()`` (#1403) + - Ability for users to leave project (#918) Changed ------- diff --git a/docs/source/_static/app_projectroles/sodar_role_list.png b/docs/source/_static/app_projectroles/sodar_role_list.png index bb6ba246..3150769d 100644 Binary files a/docs/source/_static/app_projectroles/sodar_role_list.png and b/docs/source/_static/app_projectroles/sodar_role_list.png differ diff --git a/docs/source/app_projectroles_usage.rst b/docs/source/app_projectroles_usage.rst index 0d70cd78..d035a570 100644 --- a/docs/source/app_projectroles_usage.rst +++ b/docs/source/app_projectroles_usage.rst @@ -319,9 +319,9 @@ category (owner or delegate) or superuser status. Project member list view All members of categories automatically inherit identical access rights to -subcategories and projects under those categories, starting in SODAR Core -v0.13. Inherited member roles can be promoted to a higher local role, but -demoting to a lesser role for child categories or projects is not allowed. +subcategories and projects under those categories. Inherited member roles can be +promoted to a higher local role, but demoting to a lesser role for child +categories or projects is not allowed. For inherited members, the member list displays a link to the category where the inheritance is derived from. Inherited members can not be removed or edited @@ -369,6 +369,22 @@ Invites expire after a certain time and can be reissued or revoked on the Inviting a user is prohibited if they already have an active invite in a parent category of the current category or project. +Leaving a Project +----------------- + +A user may leave a category or project by clicking the +:guilabel:`Leave Category` or :guilabel:`Leave Project` button in the role list +view. Leaving a category will also remove the user's access to child categories +and projects, except for cases where another role has specifically been assigned +for them in children. + +This operation can not be undone. To regain access, an owner or a delegate must +re-add the user to the category or project. + +Owners are not able to directly leave a project. Instead, the owner role must be +transferred to another user. To do this, the user must select +:guilabel:`Transfer Ownership` in their role dropdown. + Batch Member Modifications -------------------------- diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index 2b6b1e07..d27c21c1 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -17,6 +17,7 @@ Release Highlights ================== - Add project deletion +- Add ability for user to leave project - Add site read-only mode - Add siteappsettings site app plugin - Add removeroles management command @@ -68,7 +69,6 @@ This release enables the deletion of categories and projects. See the :ref:`project app development documentation ` for more information on how to support this feature in your apps. - AppSettingAPI Definition Getter Return Data ------------------------------------------- @@ -114,7 +114,6 @@ REST API View Changes * ``TimelineEventRetrieveAPIView``: Return ``user`` as UUID instead of ``SODARUserSerializer`` dict - Deprecated Features ------------------- diff --git a/projectroles/email.py b/projectroles/email.py index bbbd344e..b004bec6 100644 --- a/projectroles/email.py +++ b/projectroles/email.py @@ -10,7 +10,11 @@ from django.utils.timezone import localtime from projectroles.app_settings import AppSettingAPI -from projectroles.models import SODARUserAdditionalEmail +from projectroles.models import ( + SODARUserAdditionalEmail, + SODAR_CONSTANTS, + ROLE_RANKING, +) from projectroles.plugins import get_app_plugin from projectroles.utils import get_display_name @@ -26,6 +30,9 @@ DEBUG = settings.DEBUG SITE_TITLE = settings.SITE_INSTANCE_TITLE +# SODAR constants +PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] + # Local constants APP_NAME = 'projectroles' EMAIL_RE = re.compile(r'(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)') @@ -67,6 +74,7 @@ SUBJECT_ROLE_CREATE = 'Membership granted for {project_label} "{project}"' SUBJECT_ROLE_UPDATE = 'Membership changed in {project_label} "{project}"' SUBJECT_ROLE_DELETE = 'Membership removed from {project_label} "{project}"' +SUBJECT_ROLE_LEAVE = 'Member {user_name} left {project_label} "{project}"' MESSAGE_ROLE_CREATE = r''' {issuer} has granted you the membership @@ -90,6 +98,12 @@ {issuer} has removed your membership from {project_label} "{project}". '''.lstrip() +MESSAGE_ROLE_LEAVE = r''' +Member {user_name} has left the {project_label} "{project}". +For them to regain access, it has to be granted again by {project_label} +owner or delegate. +'''.lstrip() + # Invite Template -------------------------------------------------------------- @@ -505,6 +519,34 @@ def send_role_change_mail(change_type, project, user, role, request): ) +def send_project_leave_mail(project, user, request=None): + """ + Send email to project owners and delegates when a user leaves a project. + + :param project: Project object + :param user: User object + :param request: HttpRequest object or None + :return: Amount of sent email (int) + """ + p_label = get_display_name(project.type) + subject = SUBJECT_ROLE_LEAVE.format( + user_name=user.username, project_label=p_label, project=project.title + ) + message = MESSAGE_ROLE_LEAVE.format( + user_name=user.username, project_label=p_label, project=project.title + ) + mail_count = 0 + recipients = [ + a.user + for a in project.get_roles(max_rank=ROLE_RANKING[PROJECT_ROLE_DELEGATE]) + if a.user != user + and app_settings.get(APP_NAME, 'notify_email_role', user=a.user) + ] + for r in recipients: + mail_count += send_mail(subject, message, get_user_addr(r), request) + return mail_count + + def send_invite_mail(invite, request): """ Send an email invitation to user not yet registered in the system. diff --git a/projectroles/templates/projectroles/_project_role_ops.html b/projectroles/templates/projectroles/_project_role_ops.html index 128f1bc0..20f3e3aa 100644 --- a/projectroles/templates/projectroles/_project_role_ops.html +++ b/projectroles/templates/projectroles/_project_role_ops.html @@ -1,44 +1,42 @@ -
- - + + diff --git a/projectroles/templates/projectroles/project_roles.html b/projectroles/templates/projectroles/project_roles.html index 5acd09ba..781ceb13 100644 --- a/projectroles/templates/projectroles/project_roles.html +++ b/projectroles/templates/projectroles/project_roles.html @@ -33,15 +33,33 @@ {% get_role_perms project request.user as role_perms %} {% get_info_link finder_info as finder_info_link %} +{% get_display_name project.type title=True as project_type_title %}

- {% get_display_name project.type title=True %} Members + {{ project_type_title }} Members

+
+ + + + Leave {{ project_type_title }} + + {% if role_perms.can_update_members or role_perms.can_invite %} {% include 'projectroles/_project_role_ops.html' %} {% endif %} +
diff --git a/projectroles/templates/projectroles/roleassignment_confirm_delete_own.html b/projectroles/templates/projectroles/roleassignment_confirm_delete_own.html new file mode 100644 index 00000000..fce1da46 --- /dev/null +++ b/projectroles/templates/projectroles/roleassignment_confirm_delete_own.html @@ -0,0 +1,55 @@ +{% extends 'projectroles/project_base.html' %} + +{% load rules %} +{% load projectroles_tags %} +{% load projectroles_common_tags %} + +{% block title %} + Confirm Leaving {{ object.project.title }} +{% endblock title %} + +{% block projectroles_extend %} + +{% get_display_name object.project.type as project_type %} + +
+

Confirm Leaving {{ object.project.title }}

+ + + {% if inh_child_projects %} + + {% endif %} + +
+ {% csrf_token %} +
+ + Cancel + + +
+
+
+ +{% endblock projectroles_extend %} diff --git a/projectroles/tests/test_permissions.py b/projectroles/tests/test_permissions.py index 6b72d27a..ef3ea9ee 100644 --- a/projectroles/tests/test_permissions.py +++ b/projectroles/tests/test_permissions.py @@ -283,202 +283,64 @@ class TestGeneralViews(ProjectPermissionTestBase): def test_get_home(self): """Test HomeView GET""" url = reverse('home') - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - ] - bad_users = [self.anonymous] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(url, self.auth_users, 200) + self.assert_response(url, self.anonymous, 302) @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) def test_get_home_anon(self): """Test HomeView GET with anonymous access""" url = reverse('home') - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, good_users, 200) + self.assert_response(url, self.all_users, 200) def test_get_home_read_only(self): """Test HomeView GET with site read-only mode""" self.set_site_read_only() url = reverse('home') - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - ] - bad_users = [self.anonymous] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(url, self.auth_users, 200) + self.assert_response(url, self.anonymous, 302) def test_get_search(self): """Test ProjectSearchResultsView GET""" url = reverse('projectroles:search') + '?' + urlencode({'s': 'test'}) - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - ] - bad_users = [self.anonymous] - self.assert_response(url, good_users, 200) - self.assert_response(reverse('home'), bad_users, 302) + self.assert_response(url, self.auth_users, 200) + self.assert_response(url, self.anonymous, 302) @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) def test_get_search_anon(self): """Test ProjectSearchResultsView GET with anonymous access""" url = reverse('projectroles:search') + '?' + urlencode({'s': 'test'}) - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, good_users, 200) + self.assert_response(url, self.all_users, 200) def test_get_search_read_only(self): """Test ProjectSearchResultsView GET with site read-only mode""" self.set_site_read_only() url = reverse('projectroles:search') + '?' + urlencode({'s': 'test'}) - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - ] - bad_users = [self.anonymous] - self.assert_response(url, good_users, 200) - self.assert_response(reverse('home'), bad_users, 302) + self.assert_response(url, self.auth_users, 200) + self.assert_response(url, self.anonymous, 302) def test_get_search_advanced(self): """Test ProjectAdvancedSearchView GET""" url = reverse('projectroles:search_advanced') - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - ] - bad_users = [self.anonymous] - self.assert_response(url, good_users, 200) - self.assert_response(reverse('home'), bad_users, 302) + self.assert_response(url, self.auth_users, 200) + self.assert_response(url, self.anonymous, 302) @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) def test_get_search_advanced_anon(self): """Test ProjectAdvancedSearchView GET with anonymous access""" url = reverse('projectroles:search_advanced') - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, good_users, 200) + self.assert_response(url, self.all_users, 200) def test_get_login(self): """Test LoginView GET""" url = reverse('login') - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.anonymous, - ] - self.assert_response(url, good_users, 200) + self.assert_response(url, self.all_users, 200) def test_get_logout(self): """Test logout view GET""" url = reverse('logout') - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - ] self.assert_response( url, - good_users, + self.auth_users, 302, redirect_user='/login/', redirect_anon='/login/', @@ -487,24 +349,10 @@ def test_get_logout(self): def test_get_admin(self): """Test admin view GET""" url = '/admin/' - good_users = [self.superuser] - bad_users = [ - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, good_users, 200) + self.assert_response(url, self.superuser, 200) self.assert_response( url, - bad_users, + self.non_superusers, 302, redirect_user='/admin/login/?next=/admin/', redirect_anon='/admin/login/?next=/admin/', @@ -514,24 +362,10 @@ def test_get_admin(self): def test_get_admin_anon(self): """Test admin view GET with anonymous access""" url = '/admin/' - good_users = [self.superuser] - bad_users = [ - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, good_users, 200) + self.assert_response(url, self.superuser, 200) self.assert_response( url, - bad_users, + self.non_superusers, 302, redirect_user='/admin/login/?next=/admin/', redirect_anon='/admin/login/?next=/admin/', @@ -967,21 +801,7 @@ def test_get_read_only(self): def test_get_category_with_children(self): """Test GET with category and children""" - bad_users = [ - self.superuser, # We don't even allow this for superusers - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(self.url_cat, bad_users, 302) + self.assert_response(self.url_cat, self.all_users, 302) self.project.set_public() self.assert_response(self.url_cat, self.user_no_roles, 302) @@ -1031,21 +851,7 @@ def test_get_remote_not_revoked(self): site=site, level=SODAR_CONSTANTS['REMOTE_LEVEL_READ_ROLES'], ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(self.url, bad_users, 302) + self.assert_response(self.url, self.all_users, 302) self.project.set_public() self.assert_response(self.url, self.user_no_roles, 302) @@ -1107,21 +913,7 @@ def test_get_remote_not_revoked_target(self): site=site, level=SODAR_CONSTANTS['REMOTE_LEVEL_READ_ROLES'], ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(self.url, bad_users, 302) + self.assert_response(self.url, self.all_users, 302) self.project.set_public() self.assert_response(self.url, self.user_no_roles, 302) @@ -1451,21 +1243,7 @@ def test_get_owner(self): 'projectroles:role_update', kwargs={'roleassignment': self.owner_as.sodar_uuid}, ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, bad_users, 302) + self.assert_response(url, self.all_users, 302) self.project.set_public() self.assert_response(url, self.user_no_roles, 302) @@ -1608,21 +1386,7 @@ def test_get_owner(self): 'projectroles:role_delete', kwargs={'roleassignment': self.owner_as.sodar_uuid}, ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, bad_users, 302) + self.assert_response(url, self.all_users, 302) self.project.set_public() self.assert_response(url, self.user_no_roles, 302) @@ -1664,6 +1428,87 @@ def test_get_delegate(self): self.assert_response(url, self.user_no_roles, 302) +class TestRoleAssignmentOwnDeleteView(ProjectPermissionTestBase): + """Tests for RoleAssignmentOwnDeleteView permissions""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'projectroles:role_delete_own', + kwargs={'roleassignment': self.contributor_as.sodar_uuid}, + ) + self.url_cat = reverse( + 'projectroles:role_delete_own', + kwargs={'roleassignment': self.contributor_as_cat.sodar_uuid}, + ) + + def test_get(self): + """Test RoleAssignmentOwnDeleteView GET""" + good_users = [self.user_contributor] + bad_users = [u for u in self.all_users if u != self.user_contributor] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + self.project.set_public() + self.assert_response(self.url, self.user_no_roles, 302) + + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_anon(self): + """Test GET with anonymous access""" + self.project.set_public() + self.assert_response( + self.url, [self.user_no_roles, self.anonymous], 302 + ) + + def test_get_archive(self): + """Test GET with archived project""" + self.project.set_archive() + good_users = [self.user_contributor] + bad_users = [u for u in self.all_users if u != self.user_contributor] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + self.project.set_public() + self.assert_response(self.url, self.user_no_roles, 302) + + def test_get_read_only(self): + """Test GET with site read-only mode""" + self.set_site_read_only() + self.assert_response(self.url, self.all_users, 302) + + def test_get_category(self): + """Test GET with category""" + good_users = [self.user_contributor_cat] + bad_users = [ + u for u in self.all_users if u != self.user_contributor_cat + ] + self.assert_response(self.url_cat, good_users, 200) + self.assert_response(self.url_cat, bad_users, 302) + self.project.set_public() + self.assert_response(self.url_cat, self.user_no_roles, 302) + + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_category_anon(self): + """Test GET with category and anonymous access""" + self.project.set_public() + self.assert_response( + self.url_cat, [self.user_no_roles, self.anonymous], 302 + ) + + def test_get_category_read_only(self): + """Test GET with category and site read-only mode""" + self.set_site_read_only() + self.assert_response(self.url, self.all_users, 302) + + def test_get_owner(self): + """Test GET with owner role (should fail)""" + url = reverse( + 'projectroles:role_delete_own', + kwargs={'roleassignment': self.owner_as.sodar_uuid}, + ) + self.assert_response(url, self.all_users, 302) + self.project.set_public() + self.assert_response(url, self.user_no_roles, 302) + + class TestRoleAssignmentOwnerTransferView(ProjectPermissionTestBase): """Tests for RoleAssignmentOwnerTransferView permissions""" @@ -2241,22 +2086,8 @@ def test_get_project_update(self): def test_get_create_top(self): """Test ProjectCreateView GET""" url = reverse('projectroles:create') - good_users = [self.superuser] - bad_users = [ - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(url, self.superuser, 200) + self.assert_response(url, self.non_superusers, 302) # TODO: Add separate tests for local/remote creation # TODO: Remote creation should fail @@ -2291,21 +2122,7 @@ def test_get_project_create_remote(self): url = reverse( 'projectroles:create', kwargs={'project': self.category.sodar_uuid} ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, bad_users, 302) + self.assert_response(url, self.all_users, 302) @override_settings(PROJECTROLES_TARGET_CREATE=False) def test_get_project_create_disallowed(self): @@ -2313,21 +2130,9 @@ def test_get_project_create_disallowed(self): url = reverse( 'projectroles:create', kwargs={'project': self.category.sodar_uuid} ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, bad_users, 302, redirect_anon=reverse('home')) + self.assert_response( + url, self.all_users, 302, redirect_anon=reverse('home') + ) def test_get_role_create(self): """Test RoleAssignmentCreateView GET""" @@ -2335,21 +2140,9 @@ def test_get_role_create(self): 'projectroles:role_create', kwargs={'project': self.project.sodar_uuid}, ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, bad_users, 302, redirect_anon=reverse('home')) + self.assert_response( + url, self.all_users, 302, redirect_anon=reverse('home') + ) def test_get_role_update(self): """Test RoleAssignmentUpdateView GET""" @@ -2357,21 +2150,9 @@ def test_get_role_update(self): 'projectroles:role_update', kwargs={'roleassignment': self.contributor_as.sodar_uuid}, ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, bad_users, 302, redirect_anon=reverse('home')) + self.assert_response( + url, self.all_users, 302, redirect_anon=reverse('home') + ) def test_get_role_update_delegate(self): """Test RoleAssignmentUpdateView GET for delegate role""" @@ -2379,21 +2160,9 @@ def test_get_role_update_delegate(self): 'projectroles:role_update', kwargs={'roleassignment': self.delegate_as.sodar_uuid}, ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, bad_users, 302, redirect_anon=reverse('home')) + self.assert_response( + url, self.all_users, 302, redirect_anon=reverse('home') + ) def test_get_role_delete(self): """Test RoleAssignmentDeleteView GET""" @@ -2401,20 +2170,10 @@ def test_get_role_delete(self): 'projectroles:role_delete', kwargs={'roleassignment': self.contributor_as.sodar_uuid}, ) - bad_users = [ - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, bad_users, 302, redirect_anon=reverse('home')) + # TODO: Superuser? + self.assert_response( + url, self.all_users, 302, redirect_anon=reverse('home') + ) def test_get_role_delete_delegate(self): """Test RoleAssignmentDeleteView GET for delegate role""" @@ -2422,21 +2181,9 @@ def test_get_role_delete_delegate(self): 'projectroles:role_delete', kwargs={'roleassignment': self.delegate_as.sodar_uuid}, ) - bad_users = [ - self.anonymous, - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - ] - self.assert_response(url, bad_users, 302, redirect_anon=reverse('home')) + self.assert_response( + url, self.all_users, 302, redirect_anon=reverse('home') + ) def test_get_project_invite_create(self): """Test ProjectInviteCreateView GET""" @@ -2444,42 +2191,18 @@ def test_get_project_invite_create(self): 'projectroles:invite_create', kwargs={'project': self.project.sodar_uuid}, ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, bad_users, 302, redirect_anon=reverse('home')) + self.assert_response( + url, self.all_users, 302, redirect_anon=reverse('home') + ) def test_get_project_invite_list(self): """Test ProjectInviteListView GET""" url = reverse( 'projectroles:invites', kwargs={'project': self.project.sodar_uuid} ) - bad_users = [ - self.superuser, - self.user_owner_cat, - self.user_delegate_cat, - self.user_contributor_cat, - self.user_guest_cat, - self.user_finder_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, - self.user_guest, - self.user_no_roles, - self.anonymous, - ] - self.assert_response(url, bad_users, 302, redirect_anon=reverse('home')) + self.assert_response( + url, self.all_users, 302, redirect_anon=reverse('home') + ) @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) diff --git a/projectroles/tests/test_ui.py b/projectroles/tests/test_ui.py index 4bf1c252..a11d96e1 100644 --- a/projectroles/tests/test_ui.py +++ b/projectroles/tests/test_ui.py @@ -2107,6 +2107,32 @@ def setUp(self): 'projectroles:roles', kwargs={'project': self.project.sodar_uuid} ) + def test_leave_button_owner(self): + """Test rendering leave project button as owner""" + self.login_and_redirect(self.user_owner, self.url) + elem = self.selenium.find_element(By.ID, 'sodar-pr-btn-leave-project') + self.assertEqual(elem.get_attribute('disabled'), 'true') + + def test_leave_button_contributor(self): + """Test rendering leave project button as contributor""" + self.login_and_redirect(self.user_contributor, self.url) + elem = self.selenium.find_element(By.ID, 'sodar-pr-btn-leave-project') + self.assertIsNone(elem.get_attribute('disabled')) + + def test_leave_button_inherit(self): + """Test rendering leave project button as inherited contributor""" + self.login_and_redirect(self.user_contributor_cat, self.url) + elem = self.selenium.find_element(By.ID, 'sodar-pr-btn-leave-project') + self.assertEqual(elem.get_attribute('disabled'), 'true') + + @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) + def test_leave_button_target(self): + """Test rendering leave project button as target""" + self.set_up_as_target(projects=[self.category, self.project]) + self.login_and_redirect(self.user_contributor, self.url) + elem = self.selenium.find_element(By.ID, 'sodar-pr-btn-leave-project') + self.assertEqual(elem.get_attribute('disabled'), 'true') + def test_role_ops(self): """Test rendering role operations dropdown""" good_users = [self.superuser, self.user_owner, self.user_delegate] @@ -2228,7 +2254,6 @@ def test_role_dropdown_owner_local(self): self.assertIsNotNone( elem.find_element(By.CLASS_NAME, 'sodar-pr-role-item-transfer') ) - # TODO: Assert link enabling/disabling after updating owner transfer def test_role_dropdown_owner_inherited(self): """Test role dropdown items for inherited owner""" diff --git a/projectroles/tests/test_views.py b/projectroles/tests/test_views.py index 465441cb..7627c2bf 100644 --- a/projectroles/tests/test_views.py +++ b/projectroles/tests/test_views.py @@ -34,6 +34,7 @@ SUBJECT_ROLE_CREATE, SUBJECT_ROLE_UPDATE, SUBJECT_ROLE_DELETE, + SUBJECT_ROLE_LEAVE, SUBJECT_ACCEPT, SUBJECT_EXPIRY, ) @@ -69,6 +70,9 @@ FORM_INVALID_MSG, PROJECT_WELCOME_MSG, USER_PROFILE_LDAP_MSG, + ROLE_LEAVE_INHERIT_MSG, + ROLE_LEAVE_OWNER_MSG, + ROLE_LEAVE_REMOTE_MSG, ROLE_FINDER_INFO, INVITE_LDAP_LOCAL_VIEW_MSG, INVITE_LOCAL_NOT_ALLOWED_MSG, @@ -2823,49 +2827,63 @@ def test_post_invalid_host_name(self): self.assertEqual(Project.objects.count(), 2) -class TestProjectRoleView(ProjectMixin, RoleAssignmentMixin, ViewTestBase): +class TestProjectRoleView( + ProjectMixin, RoleAssignmentMixin, RemoteTargetMixin, ViewTestBase +): """Tests for ProjectRoleView""" def setUp(self): super().setUp() + # Set up users + self.user_owner_cat = self.make_user('user_owner_cat') + self.user_owner = self.make_user('user_owner') + self.user_delegate = self.make_user('user_delegate') + self.user_guest = self.make_user('user_guest') + # Set up projects and roles + self.category = self.make_project( + 'TestCategory', PROJECT_TYPE_CATEGORY, None + ) + self.owner_as_cat = self.make_assignment( + self.category, self.user_owner_cat, self.role_owner + ) self.project = self.make_project( - 'TestProject', PROJECT_TYPE_PROJECT, None + 'TestProject', PROJECT_TYPE_PROJECT, self.category ) - # Set superuser as owner self.owner_as = self.make_assignment( - self.project, self.user, self.role_owner + self.project, self.user_owner, self.role_owner ) - # Set new user as delegate - self.user_delegate = self.make_user('user_delegate') self.delegate_as = self.make_assignment( self.project, self.user_delegate, self.role_delegate ) - # Set another new user as guest (= one of the member roles) - self.user_guest = self.make_user('user_guest') self.guest_as = self.make_assignment( self.project, self.user_guest, self.role_guest ) + self.url = reverse( + 'projectroles:roles', + kwargs={'project': self.project.sodar_uuid}, + ) def test_get(self): """Test ProjectRoleView GET""" - with self.login(self.user): - response = self.client.get( - reverse( - 'projectroles:roles', - kwargs={'project': self.project.sodar_uuid}, - ) - ) + with self.login(self.user_owner): + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertEqual(response.context['project'].pk, self.project.pk) - expected = [ { 'id': self.owner_as.pk, 'project': self.project.pk, 'role': self.role_owner.pk, - 'user': self.user.pk, + 'user': self.user_owner.pk, 'sodar_uuid': self.owner_as.sodar_uuid, }, + { + 'id': self.owner_as_cat.pk, + 'project': self.category.pk, + 'role': self.role_owner.pk, + 'user': self.user_owner_cat.pk, + 'sodar_uuid': self.owner_as_cat.sodar_uuid, + }, { 'id': self.delegate_as.pk, 'project': self.project.pk, @@ -2891,10 +2909,51 @@ def test_get(self): categories='categories', projects='projects' ), ) + self.assertEqual(response.context['own_local_as'], self.owner_as) + self.assertEqual(response.context['project_leave_access'], False) + self.assertEqual( + response.context['project_leave_msg'], ROLE_LEAVE_OWNER_MSG + ) + + def test_get_inherited(self): + """Test GET as user with inherited role""" + with self.login(self.user_owner_cat): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['own_local_as'], None) + self.assertEqual(response.context['project_leave_access'], False) + self.assertEqual( + response.context['project_leave_msg'], + ROLE_LEAVE_INHERIT_MSG.format(category_type='category'), + ) + + def test_get_guest(self): + """Test GET as guest""" + with self.login(self.user_guest): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['own_local_as'], self.guest_as) + self.assertEqual(response.context['project_leave_access'], True) + self.assertEqual(response.context['project_leave_msg'], '') + + @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) + def test_get_guest_target(self): + """Test GET as guest on target site""" + self.set_up_as_target([self.project]) + self.assertEqual(self.project.is_remote(), True) + with self.login(self.user_guest): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['own_local_as'], self.guest_as) + self.assertEqual(response.context['project_leave_access'], False) + self.assertEqual( + response.context['project_leave_msg'], + ROLE_LEAVE_REMOTE_MSG.format(project_type='Project'), + ) def test_get_not_found(self): """Test GET view with invalid project UUID""" - with self.login(self.user): + with self.login(self.user_owner): response = self.client.get( reverse( 'projectroles:roles', @@ -3815,6 +3874,9 @@ def test_post(self): ) self.assertEqual(alert.active, True) self.assertEqual(RoleAssignment.objects.all().count(), 3) + self.assertEqual( + TimelineEvent.objects.filter(event_name='role_delete').count(), 0 + ) self.assertEqual( self.app_alert_model.objects.filter( alert_name='role_delete' @@ -3832,6 +3894,9 @@ def test_post(self): ), ) self.assertEqual(RoleAssignment.objects.all().count(), 2) + self.assertEqual( + TimelineEvent.objects.filter(event_name='role_delete').count(), 1 + ) self.assertEqual( self.app_alert_model.objects.filter( alert_name='role_delete' @@ -4070,6 +4135,154 @@ def test_post_app_settings_children(self): ) +class TestRoleAssignmentOwnDeleteView( + ProjectMixin, RoleAssignmentMixin, ViewTestBase +): + """Tests for RoleAssignmentOwnDeleteView""" + + def setUp(self): + super().setUp() + self.category = self.make_project( + 'TestCategory', PROJECT_TYPE_CATEGORY, None + ) + self.owner_as_cat = self.make_assignment( + self.category, self.user, self.role_owner + ) + self.project = self.make_project( + 'TestProject', PROJECT_TYPE_PROJECT, self.category + ) + self.owner_as = self.make_assignment( + self.project, self.user, self.role_owner + ) + # Create guest user and role + self.user_contrib = self.make_user('user_contrib') + self.contrib_as = self.make_assignment( + self.project, self.user_contrib, self.role_contributor + ) + self.user_new = self.make_user('user_new') + # Set up helpers + self.app_alerts = get_backend_api('appalerts_backend') + self.app_alert_model = self.app_alerts.get_model() + + def test_get(self): + """Test RoleAssignmentOwnDeleteView GET""" + url = reverse( + 'projectroles:role_delete_own', + kwargs={'roleassignment': self.contrib_as.sodar_uuid}, + ) + with self.login(self.user_contrib): + response = self.client.get(url) + self.assertEqual(response.context['inh_child_projects'], []) + self.assertEqual(response.status_code, 200) + + def test_get_category_inherited(self): + """Test GET with category and inherited role""" + new_as = self.make_assignment( + self.category, self.user_new, self.role_contributor + ) + url = reverse( + 'projectroles:role_delete_own', + kwargs={'roleassignment': new_as.sodar_uuid}, + ) + with self.login(self.user_new): + response = self.client.get(url) + self.assertEqual(response.context['inh_child_projects'], [self.project]) + self.assertEqual(response.status_code, 200) + + def test_get_category_child_local(self): + """Test GET with category and local child role""" + new_as = self.make_assignment( + self.category, self.user_new, self.role_contributor + ) + self.make_assignment(self.project, self.user_new, self.role_contributor) + url = reverse( + 'projectroles:role_delete_own', + kwargs={'roleassignment': new_as.sodar_uuid}, + ) + with self.login(self.user_new): + response = self.client.get(url) + self.assertEqual(response.context['inh_child_projects'], []) + self.assertEqual(response.status_code, 200) + + def test_get_owner(self): + """Test GET with owner role (should fail)""" + url = reverse( + 'projectroles:role_delete_own', + kwargs={'roleassignment': self.owner_as.sodar_uuid}, + ) + with self.login(self.user): + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_post(self): + """Test POST""" + self.assertEqual(RoleAssignment.objects.count(), 3) + self.assertIsNotNone( + RoleAssignment.objects.filter( + project=self.project, user=self.user_contrib + ).first() + ) + self.assertEqual( + TimelineEvent.objects.filter(event_name='role_delete').count(), 0 + ) + self.assertEqual( + self.app_alert_model.objects.filter( + alert_name='role_delete_own' + ).count(), + 0, + ) + self.assertEqual(len(mail.outbox), 0) + + url = reverse( + 'projectroles:role_delete_own', + kwargs={'roleassignment': self.contrib_as.sodar_uuid}, + ) + with self.login(self.user_contrib): + response = self.client.post(url) + self.assertRedirects(response, reverse('home')) + + self.assertEqual(RoleAssignment.objects.count(), 2) + self.assertIsNone( + RoleAssignment.objects.filter( + project=self.project, user=self.user_contrib + ).first() + ) + self.assertEqual( + TimelineEvent.objects.filter(event_name='role_delete').count(), 1 + ) + self.assertEqual( + self.app_alert_model.objects.filter( + alert_name='role_delete_own' + ).count(), + 1, + ) + self.assertEqual( + self.app_alert_model.objects.get(alert_name='role_delete_own').user, + self.user, + ) + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + SUBJECT_ROLE_LEAVE.format( + user_name=self.user_contrib.username, + project_label='project', + project=self.project.title, + ), + mail.outbox[0].subject, + ) + + def test_post_owner(self): + """Test POST with owner role (should fail)""" + self.assertEqual(RoleAssignment.objects.count(), 3) + url = reverse( + 'projectroles:role_delete_own', + kwargs={'roleassignment': self.owner_as.sodar_uuid}, + ) + with self.login(self.user): + response = self.client.post(url) + self.assertRedirects(response, reverse('home')) + self.assertEqual(RoleAssignment.objects.count(), 3) + + class TestRoleAssignmentOwnerTransferView( ProjectMixin, RoleAssignmentMixin, ViewTestBase ): diff --git a/projectroles/urls.py b/projectroles/urls.py index c8db589e..b841a5a0 100644 --- a/projectroles/urls.py +++ b/projectroles/urls.py @@ -78,6 +78,11 @@ view=views.RoleAssignmentDeleteView.as_view(), name='role_delete', ), + path( + route='members/delete/own/', + view=views.RoleAssignmentOwnDeleteView.as_view(), + name='role_delete_own', + ), path( route='members/owner/transfer/', view=views.RoleAssignmentOwnerTransferView.as_view(), diff --git a/projectroles/views.py b/projectroles/views.py index 3caa6d7a..8b6f97af 100644 --- a/projectroles/views.py +++ b/projectroles/views.py @@ -126,6 +126,12 @@ ROLE_CREATE_MSG = 'Membership granted with the role of "{role}".' ROLE_UPDATE_MSG = 'Member role changed to "{role}".' ROLE_DELETE_MSG = 'Your membership in this {project_type} has been removed.' +ROLE_LEAVE_MSG = 'Member {user_name} left the {project_type}.' +ROLE_LEAVE_INHERIT_MSG = 'Role inherited from parent {category_type}' +ROLE_LEAVE_OWNER_MSG = 'Owner role must be transferred to another user' +ROLE_LEAVE_REMOTE_MSG = ( + '{project_type} is remote, role must be changed on source site' +) ROLE_FINDER_INFO = ( 'User can see nested {categories} and {projects}, but can not access them ' 'without having a role explicitly assigned.' @@ -1906,6 +1912,7 @@ class ProjectRoleView( def get_context_data(self, *args, **kwargs): project = self.get_project() + project_remote = project.is_remote() context = super().get_context_data(*args, **kwargs) context['roles'] = sorted( project.get_roles(), key=lambda x: [x.role.rank, x.user.username] @@ -1924,6 +1931,28 @@ def get_context_data(self, *args, **kwargs): categories=get_display_name(PROJECT_TYPE_CATEGORY, plural=True), projects=get_display_name(PROJECT_TYPE_PROJECT, plural=True), ) + if self.request.user.is_authenticated: + own_local_as = RoleAssignment.objects.filter( + project=project, user=self.request.user + ).first() + context['own_local_as'] = own_local_as + context['project_leave_access'] = ( + own_local_as is not None + and own_local_as.role.rank > ROLE_RANKING[PROJECT_ROLE_OWNER] + and not project_remote + ) + leave_msg = '' + if not own_local_as: + leave_msg = ROLE_LEAVE_INHERIT_MSG.format( + category_type=get_display_name(PROJECT_TYPE_CATEGORY) + ) + elif own_local_as.role.rank == ROLE_RANKING[PROJECT_ROLE_OWNER]: + leave_msg = ROLE_LEAVE_OWNER_MSG + elif project_remote: + leave_msg = ROLE_LEAVE_REMOTE_MSG.format( + project_type=get_display_name(project.type, title=True) + ) + context['project_leave_msg'] = leave_msg return context @@ -2082,10 +2111,10 @@ def form_valid(self, form): class RoleAssignmentDeleteMixin(ProjectModifyPluginViewMixin): """Mixin for RoleAssignment deletion/destroying in UI and API views""" - @transaction.atomic - def _update_app_alerts(self, app_alerts, project, user, inh_as): + @classmethod + def _add_user_alert(cls, app_alerts, project, user, inh_as=None): """ - Update app alerts for user on role assignment deletion. Creates a new + Create app alert for user on role assignment deletion. Creates a new alert as appropriate and dismisses alerts in projects the user can no longer access. @@ -2102,22 +2131,6 @@ def _update_app_alerts(self, app_alerts, project, user, inh_as): message = ROLE_DELETE_MSG.format( project_type=get_display_name(project.type) ) - # Dismiss existing alerts - AppAlert = app_alerts.get_model() - dis_projects = [project] - if project.type == PROJECT_TYPE_CATEGORY: - for c in project.get_children(flat=True): - c_role_as = RoleAssignment.objects.filter( - project=c, user=user - ).first() - if not c.public_guest_access and not c_role_as: - dis_projects.append(c) - for a in AppAlert.objects.filter( - user=user, project__in=dis_projects, active=True - ): - a.active = False - a.save() - app_alerts.add_alert( app_name=APP_NAME, alert_name='role_{}'.format('update' if inh_as else 'delete'), @@ -2126,6 +2139,60 @@ def _update_app_alerts(self, app_alerts, project, user, inh_as): project=project, ) + @classmethod + def _add_leave_alerts(cls, app_alerts, project, user): + """ + Send alerts to project owners and delegates about user leaving. + + :param app_alerts: AppAlertAPI object + :param project: Project object + :param user: SODARUser object + """ + recipients = [ + a.user + for a in project.get_roles( + max_rank=ROLE_RANKING[PROJECT_ROLE_DELEGATE] + ) + if a.user != user + ] + for r in recipients: + app_alerts.add_alert( + app_name=APP_NAME, + alert_name='role_delete_own', + user=r, + message=ROLE_LEAVE_MSG.format( + user_name=user.username, + project_type=get_display_name(project.type), + ), + project=project, + ) + + @classmethod + @transaction.atomic + def _dismiss_user_alerts(cls, app_alerts, project, user): + """ + Dismiss user alerts in project and children without local role. + + :param app_alerts: AppAlertAPI object + :param project: Project object + :param user: SODARUser object + """ + AppAlert = app_alerts.get_model() + dis_projects = [project] + if project.type == PROJECT_TYPE_CATEGORY: + for c in project.get_children(flat=True): + c_role_as = RoleAssignment.objects.filter( + project=c, user=user + ).first() + # TODO: Fix handling of children (see #1556) + if not c.public_guest_access and not c_role_as: + dis_projects.append(c) + for a in AppAlert.objects.filter( + user=user, project__in=dis_projects, active=True + ): + a.active = False + a.save() + def delete_assignment(self, role_as, request=None, notify=True): """ Delete RoleAssignment. Calls the modify API for additional actions, @@ -2177,15 +2244,21 @@ def delete_assignment(self, role_as, request=None, notify=True): if tl_event: tl_event.set_status(timeline.TL_STATUS_OK) - if notify: - inh_as = project.get_role(user, inherited_only=True) - if app_alerts: - self._update_app_alerts(app_alerts, project, user, inh_as) - if ( - SEND_EMAIL - and request - and app_settings.get(APP_NAME, 'notify_email_role', user=user) - ): + if not notify: + return role_as + + inh_as = project.get_role(user, inherited_only=True) + if app_alerts: + if request and request.user == user: + self._add_leave_alerts(app_alerts, project, user) + else: + self._add_user_alert(app_alerts, project, user, inh_as) + if not inh_as: + self._dismiss_user_alerts(app_alerts, project, user) + if SEND_EMAIL and request: + if request and request.user == user: + email.send_project_leave_mail(project, user, request) + elif app_settings.get(APP_NAME, 'notify_email_role', user=user): if inh_as: email.send_role_change_mail( 'update', project, user, inh_as.role, request @@ -2367,6 +2440,76 @@ def post(self, *args, **kwargs): ) +class RoleAssignmentOwnDeleteView( + LoginRequiredMixin, + LoggedInPermissionMixin, + ProjectContextMixin, + RoleAssignmentDeleteMixin, + DeleteView, +): + """RoleAssignment deletion view for leaving project""" + + model = RoleAssignment + # Perm overridden in has_permission() + permission_required = 'projectroles.view_project' + slug_url_kwarg = 'roleassignment' + slug_field = 'sodar_uuid' + template_name = 'projectroles/roleassignment_confirm_delete_own.html' + + def has_permission(self): + """ + Override has_permission() for one time check for view perms. + + NOTE: Single use case so we're not writing a common rules perm + """ + role_as = self.get_object() + user = self.request.user + if ( + app_settings.get('projectroles', 'site_read_only') + or role_as.user != user + or role_as.role.rank < ROLE_RANKING[PROJECT_ROLE_DELEGATE] + ): + return False + return True + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + role_as = self.get_object() + user = role_as.user + children = role_as.project.get_children(flat=True) + local_child_projects = [ + a.project + for a in RoleAssignment.objects.filter( + user=user, project__in=children + ) + ] + context['inh_child_projects'] = [ + p for p in children if p not in local_child_projects + ] + return context + + def post(self, *args, **kwargs): + role_as = self.get_object() + project = role_as.project + try: + self.object = self.delete_assignment( + role_as=role_as, request=self.request + ) + messages.success( + self.request, 'Successfully left {}.'.format(project.title) + ) + return redirect(reverse('home')) + except Exception as ex: + messages.error( + self.request, 'Failed to leave {}: {}'.format(project.title, ex) + ) + return redirect( + reverse( + 'projectroles:roles', kwargs={'project': project.sodar_uuid} + ) + ) + + class RoleAssignmentOwnerTransferMixin(ProjectModifyPluginViewMixin): """Mixin for owner RoleAssignment transfer in UI and API views"""