From a8b9c5e9718f1ef8ad6915c43674de65fd0aacfa Mon Sep 17 00:00:00 2001 From: Lucas Brandstaetter <lucas@brandstaetter.tech> Date: Sun, 8 Dec 2024 23:40:48 +0100 Subject: [PATCH] Add team member delete view --- .../locale/de/LC_MESSAGES/django.po | 27 ++++ .../locale/en/LC_MESSAGES/django.po | 27 ++++ .../templates/backoffice/teams/detail.html | 6 + src/backoffice/tests/teams/__init__.py | 2 + src/backoffice/tests/teams/members.py | 117 ++++++++++++++++++ src/backoffice/urls.py | 1 + src/backoffice/views/teams/__init__.py | 2 + src/backoffice/views/teams/members.py | 66 +++++++++- src/backoffice/views/teams/teams.py | 9 ++ 9 files changed, 255 insertions(+), 2 deletions(-) diff --git a/src/backoffice/locale/de/LC_MESSAGES/django.po b/src/backoffice/locale/de/LC_MESSAGES/django.po index 1a514f965..487551bb5 100644 --- a/src/backoffice/locale/de/LC_MESSAGES/django.po +++ b/src/backoffice/locale/de/LC_MESSAGES/django.po @@ -1536,6 +1536,9 @@ msgstr "Verwaltungsrechte verleihen" msgid "TeamMember__remove__button" msgstr "Entfernen" +msgid "TeamMember__leave" +msgstr "Team verlassen" + msgid "Team__delete__title" msgstr "Team löschen" @@ -1714,6 +1717,16 @@ msgstr "Account angelegt" msgid "registration_sign_up_mail_sent" msgstr "Eine E-Mail mit einem Aktivierungs-Link wurde an die angegebene E-Mail-Adresse gesendet. Bitte prüfe deine Inbox und öffne den in der Mail enthaltenen Link um deinen Account zu aktivieren." +msgid "TeamMember__delete__warning__header" +msgstr "Benutzer aus dem Team entfernen?" + +msgid "TeamMember__leave__warning__header" +msgstr "Das Team wirklich verlassen?" + +# use translation from core +msgid "TeamMember__delete__cannot_delete_last_manager" +msgstr "" + # use translation from core msgid "TeamMember__clean__cannot_remove_last_manager" msgstr "" @@ -2086,6 +2099,20 @@ msgstr "%(project_type)s '%(project)s' erstellt" msgid "Project__updated %(project)s %(project_type)s" msgstr "%(project_type)s '%(project)s' aktualisiert" +#, python-format +msgid "TeamMember__leave__warning__text %(team)s" +msgstr "Das Team \"%(team)s\" wirklich verlassen?" + +msgid "TeamMember__leave__submit" +msgstr "Team verlassen" + +#, python-format +msgid "TeamMember__delete__warning__text %(team)s %(user)s" +msgstr "Den Benutzer \"%(user)s\" aus dem Team \"%(team)s\" entfernen?" + +msgid "TeamMember__delete__submit" +msgstr "Benutzer entfernen" + #, python-format msgid "Team__create__success %(name)s" msgstr "Das Team \"%(name)s\" wurde angelegt!" diff --git a/src/backoffice/locale/en/LC_MESSAGES/django.po b/src/backoffice/locale/en/LC_MESSAGES/django.po index 71beda514..ccdd21e32 100644 --- a/src/backoffice/locale/en/LC_MESSAGES/django.po +++ b/src/backoffice/locale/en/LC_MESSAGES/django.po @@ -1541,6 +1541,9 @@ msgstr "Add management rights" msgid "TeamMember__remove__button" msgstr "Remove" +msgid "TeamMember__leave" +msgstr "Leave team" + msgid "Team__delete__title" msgstr "Delete this team" @@ -1720,6 +1723,16 @@ msgstr "Account created." msgid "registration_sign_up_mail_sent" msgstr "An email with an activation link has been sent to the given e-mail address. Please check your inbox and open the link sent to you in order to activate your account." +msgid "TeamMember__delete__warning__header" +msgstr "Remove user from the team?" + +msgid "TeamMember__leave__warning__header" +msgstr "Do you really want to leave the team?" + +# use translation from core +msgid "TeamMember__delete__cannot_delete_last_manager" +msgstr "Remove user from the team?" + # use translation from core msgid "TeamMember__clean__cannot_remove_last_manager" msgstr "" @@ -2091,6 +2104,20 @@ msgstr "%(project_type)s '%(project)s' created" msgid "Project__updated %(project)s %(project_type)s" msgstr "%(project_type)s '%(project)s' updated" +#, python-format +msgid "TeamMember__leave__warning__text %(team)s" +msgstr "Really leave the team \"%(team)s\"?" + +msgid "TeamMember__leave__submit" +msgstr "Leave the team" + +#, python-format +msgid "TeamMember__delete__warning__text %(team)s %(user)s" +msgstr "Remove the user \"%(user)s\" from the team \"%(team)s\"?" + +msgid "TeamMember__delete__submit" +msgstr "Remove user" + #, python-format msgid "Team__create__success %(name)s" msgstr "The team \"%(name)s\" was successfully created." diff --git a/src/backoffice/templates/backoffice/teams/detail.html b/src/backoffice/templates/backoffice/teams/detail.html index 84e857fac..07abefea1 100644 --- a/src/backoffice/templates/backoffice/teams/detail.html +++ b/src/backoffice/templates/backoffice/teams/detail.html @@ -116,6 +116,12 @@ </table> </div> + {% if member_id %} + <div class="card-footer text-end"> + <a href="{% url "backoffice:team-member-delete" team=team.uuid pk=member_id %}" + class="btn btn-danger btn-sm">{% trans "TeamMember__leave" %}</a> + </div> + {% endif %} </div> {% if not form.create and can_delete %} diff --git a/src/backoffice/tests/teams/__init__.py b/src/backoffice/tests/teams/__init__.py index e9d9b0415..dd1be652f 100644 --- a/src/backoffice/tests/teams/__init__.py +++ b/src/backoffice/tests/teams/__init__.py @@ -1,4 +1,5 @@ from backoffice.tests.teams.members import ( + TeamMemberDeleteViewTestCase, TeamMemberUpdateViewTestCase, ) from backoffice.tests.teams.teams import ( @@ -14,6 +15,7 @@ __all__ = ( 'TeamDeleteViewTestCase', 'TeamDetailViewTestCase', 'TeamListViewTestCase', + 'TeamMemberDeleteViewTestCase', 'TeamMemberUpdateViewTestCase', 'TeamUpdateViewTestCase', ) diff --git a/src/backoffice/tests/teams/members.py b/src/backoffice/tests/teams/members.py index 3b801462c..ea2d3313b 100644 --- a/src/backoffice/tests/teams/members.py +++ b/src/backoffice/tests/teams/members.py @@ -10,6 +10,123 @@ from core.models import ActivityLogEntry, PlatformUser, Team, TeamMember from backoffice.tests.base import BackOfficeTestCase +class TeamMemberDeleteViewTestCase(BackOfficeTestCase): + def setUp(self): + super().setUp() + self.staff_2 = PlatformUser.objects.create(username='test_staff_2', is_staff=True) + self.team = Team.objects.create( + name='team', + conference=self.conf, + ) + self.team_member_staff = TeamMember.objects.create(team=self.team, user=self.staff, can_manage=True) + self.team_member_staff_2 = TeamMember.objects.create(team=self.team, user=self.staff_2, can_manage=True) + self.team_member_user = TeamMember.objects.create(team=self.team, user=self.user) + + def test_remove_member_admin(self): + activate('en') + self.client.force_login(self.admin) + activity_log_count = ActivityLogEntry.objects.count() + response = self.client.post( + reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}), + ) + self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html') + self.assertTrue(TeamMember.objects.filter(user=self.user).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count) + self.assertContains(response, _('TeamMember__delete__warning__header')) + + response = self.client.post( + reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}), + {'confirmation': 'true'}, + ) + self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid})) + self.assertFalse(TeamMember.objects.filter(user=self.user).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + def test_remove_member_staff(self): + activate('en') + self.client.force_login(self.staff) + activity_log_count = ActivityLogEntry.objects.count() + response = self.client.post( + reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}), + ) + self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html') + self.assertTrue(TeamMember.objects.filter(user=self.user).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count) + self.assertContains(response, _('TeamMember__delete__warning__header')) + + response = self.client.post( + reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}), + {'confirmation': 'true'}, + ) + self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid})) + self.assertFalse(TeamMember.objects.filter(user=self.user).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + def test_leave_member(self): + activate('en') + self.client.force_login(self.user) + activity_log_count = ActivityLogEntry.objects.count() + response = self.client.post( + reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}), + ) + self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html') + self.assertTrue(TeamMember.objects.filter(user=self.user).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count) + self.assertContains(response, _('TeamMember__leave__warning__header')) + + response = self.client.post( + reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_user.pk, 'team': self.team.uuid}), + {'confirmation': 'true'}, + ) + self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid})) + self.assertFalse(TeamMember.objects.filter(user=self.user).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + def test_cannot_remove_last_manager(self): + activate('en') + self.client.force_login(self.admin) + activity_log_count = ActivityLogEntry.objects.count() + + response = self.client.post( + reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_staff_2.pk, 'team': self.team.uuid}), + {'confirmation': 'true'}, + ) + self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid})) + self.assertFalse(TeamMember.objects.filter(user=self.staff_2).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + response = self.client.post( + reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_staff.pk, 'team': self.team.uuid}), + {'confirmation': 'true'}, + ) + self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html') + self.assertTrue(TeamMember.objects.filter(user=self.staff).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + self.assertContains(response, _('TeamMember__delete__cannot_delete_last_manager')) + + def test_cannot_leave_last_manager(self): + activate('en') + self.client.force_login(self.staff) + activity_log_count = ActivityLogEntry.objects.count() + + response = self.client.post( + reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_staff_2.pk, 'team': self.team.uuid}), + {'confirmation': 'true'}, + ) + self.assertRedirects(response, reverse('backoffice:team', kwargs={'uuid': self.team.uuid})) + self.assertFalse(TeamMember.objects.filter(user=self.staff_2).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + + response = self.client.post( + reverse('backoffice:team-member-delete', kwargs={'pk': self.team_member_staff.pk, 'team': self.team.uuid}), + {'confirmation': 'true'}, + ) + self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html') + self.assertTrue(TeamMember.objects.filter(user=self.staff).exists()) + self.assertEqual(ActivityLogEntry.objects.count(), activity_log_count + 1) + self.assertContains(response, _('TeamMember__delete__cannot_delete_last_manager')) + + class TeamMemberUpdateViewTestCase(MessagesTestMixin, BackOfficeTestCase): def setUp(self): super().setUp() diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index 08a7be983..35574ee7c 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -158,6 +158,7 @@ urlpatterns = [ path('team/<uuid:uuid>', teams.TeamDetailView.as_view(), name='team'), path('team/<uuid:uuid>/edit', teams.TeamUpdateView.as_view(), name='team-edit'), path('team/<uuid:uuid>/delete', teams.TeamDeleteView.as_view(), name='team-delete'), + path('team/<uuid:team>/member/<uuid:pk>/delete', teams.TeamMemberDeleteView.as_view(), name='team-member-delete'), path('team/<uuid:team>/member/<uuid:pk>/update', teams.TeamMemberUpdateView.as_view(), name='team-member-update'), path('vouchers', vouchers.VouchersView.as_view(), name='vouchers'), path('_boom', misc.BoomView.as_view()), diff --git a/src/backoffice/views/teams/__init__.py b/src/backoffice/views/teams/__init__.py index 6d5a45337..3bf898e25 100644 --- a/src/backoffice/views/teams/__init__.py +++ b/src/backoffice/views/teams/__init__.py @@ -1,4 +1,5 @@ from backoffice.views.teams.members import ( + TeamMemberDeleteView, TeamMemberUpdateView, ) from backoffice.views.teams.teams import ( @@ -14,6 +15,7 @@ __all__ = [ 'TeamDeleteView', 'TeamDetailView', 'TeamListView', + 'TeamMemberDeleteView', 'TeamMemberUpdateView', 'TeamUpdateView', ] diff --git a/src/backoffice/views/teams/members.py b/src/backoffice/views/teams/members.py index 665cf4c8f..bc87bba2a 100644 --- a/src/backoffice/views/teams/members.py +++ b/src/backoffice/views/teams/members.py @@ -1,10 +1,15 @@ +from typing import Any + from django.contrib.messages import ERROR, add_message -from django.http import HttpResponseRedirect +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.urls import reverse +from django.utils.translation import gettext as _ from django.views.generic import UpdateView +from django.views.generic.edit import DeleteView from rules.contrib.views import AutoPermissionRequiredMixin -from core.models import TeamMember +from core.models import ActivityLogChange, TeamMember +from core.models.teams.team_member import LastManagerError class TeamMemberUpdateView(AutoPermissionRequiredMixin, UpdateView): @@ -31,3 +36,60 @@ class TeamMemberUpdateView(AutoPermissionRequiredMixin, UpdateView): def get_success_url(self): return reverse('backoffice:team', kwargs={'uuid': self.kwargs['team']}) + + +class TeamMemberDeleteView(AutoPermissionRequiredMixin, DeleteView): + model = TeamMember + + template_name = 'backoffice/components/confirmation_modal.html' + + def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse: + """ + Delete the team member if the confirmation is confirmed. + """ + if request.POST.get('confirmation', request.GET.get('confirmation', 'false')) != 'true': + return self.get(request, *args, **kwargs) + try: + return super().post(request, *args, **kwargs) + except LastManagerError: + add_message(request, ERROR, _('TeamMember__delete__cannot_delete_last_manager')) + return self.get(request, *args, **kwargs) + + def form_valid(self, form): + if not TeamMember.type_is(team_member := self.get_object()): # pragma: no cover + raise ValueError('The object is not a TeamMember.') + team = team_member.team + old_members = ', '.join(team.members.all().values_list('user__display_name', flat=True)) + old_count = str(team.members.count()) + response = super().form_valid(form) + team.log_activity( + self.request.user, + members=ActivityLogChange(old=old_members, new=', '.join(team.members.all().values_list('user__display_name', flat=True))), + members_count=ActivityLogChange(old=old_count, new=str(team.members.count())), + ) + + return response + + def get_context_data(self, **kwargs) -> dict[str, Any]: + if not TeamMember.type_is(team_member := self.get_object()): # pragma: no cover + raise ValueError('The object is not a TeamMember.') + if team_member.user == self.request.user: + return { + 'confirmation_title': _('TeamMember__leave__warning__header'), + 'confirmation_body': _('TeamMember__leave__warning__text %(team)s') % {'team': team_member.team.name}, + 'confirmation_class': 'danger', + 'confirmation_submit': _('TeamMember__leave__submit'), + 'confirmation_cancel_url': reverse('backoffice:team', kwargs={'uuid': team_member.team.uuid}), + **super().get_context_data(**kwargs), + } + return { + 'confirmation_title': _('TeamMember__delete__warning__header'), + 'confirmation_body': _('TeamMember__delete__warning__text %(team)s %(user)s') % {'team': team_member.team.name, 'user': team_member.user.username}, + 'confirmation_class': 'danger', + 'confirmation_submit': _('TeamMember__delete__submit'), + 'confirmation_cancel_url': reverse('backoffice:team', kwargs={'uuid': team_member.team.uuid}), + **super().get_context_data(**kwargs), + } + + def get_success_url(self): + return reverse('backoffice:team', kwargs={'uuid': self.kwargs['team']}) diff --git a/src/backoffice/views/teams/teams.py b/src/backoffice/views/teams/teams.py index 6e185c487..d9a4abe6f 100644 --- a/src/backoffice/views/teams/teams.py +++ b/src/backoffice/views/teams/teams.py @@ -100,6 +100,15 @@ class TeamDetailView(SingleUUIDObjectMixin, TeamNavContextMixin, AutoPermissionR def get_queryset(self) -> QuerySet[Team]: return super().get_queryset().prefetch_related('members__user') + def get_context_data(self, **kwargs) -> dict[str, Any]: + if not Team.type_is(team := self.object): # pragma: no cover + raise ValueError('Invalid object type') + member_id = self.get_queryset().filter(uuid=team.uuid, members__user=self.request.user).values_list('members__id', flat=True).first() + return { + **super().get_context_data(**kwargs), + 'member_id': member_id, + } + class TeamFormMixin(AutoPermissionRequiredMixin, TeamNavContextMixin, FormMesssageMixin, FormView): model = Team -- GitLab