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