From 13e8fea19900c0fb05a412479d2a095361df2811 Mon Sep 17 00:00:00 2001
From: Lucas Brandstaetter <lucas@brandstaetter.tech>
Date: Mon, 9 Dec 2024 00:19:26 +0100
Subject: [PATCH] Add team delete view

- Add delete view for team
- Add confirmation as fallback for non javascript users
---
 .../locale/de/LC_MESSAGES/django.po           | 28 ++++++++++
 .../locale/en/LC_MESSAGES/django.po           | 28 ++++++++++
 .../components/confirmation_modal.html        | 25 +++++++++
 .../templates/backoffice/teams/detail.html    | 50 +++++++++++++++++-
 src/backoffice/tests/teams/__init__.py        |  2 +
 src/backoffice/tests/teams/teams.py           | 51 +++++++++++++++++++
 src/backoffice/views/teams/__init__.py        |  2 +
 src/backoffice/views/teams/teams.py           | 27 +++++++++-
 8 files changed, 210 insertions(+), 3 deletions(-)
 create mode 100644 src/backoffice/templates/backoffice/components/confirmation_modal.html

diff --git a/src/backoffice/locale/de/LC_MESSAGES/django.po b/src/backoffice/locale/de/LC_MESSAGES/django.po
index cf32789e8..1a514f965 100644
--- a/src/backoffice/locale/de/LC_MESSAGES/django.po
+++ b/src/backoffice/locale/de/LC_MESSAGES/django.po
@@ -820,6 +820,9 @@ msgstr "Speichern"
 msgid "ActivityLog_no_entries"
 msgstr "Keine Activity Log Einträge"
 
+msgid "Cancel"
+msgstr "Abbrechen"
+
 #, python-format
 msgid "Invitation__description %(requester)s %(requested)s %(type)s"
 msgstr "Die Einladung von  \"%(requester)s\" an \"%(requested)s\" (Art: %(type)s)"
@@ -1478,6 +1481,16 @@ msgstr "Speichern"
 msgid "Details"
 msgstr ""
 
+msgid "Team__delete__warning__header"
+msgstr "Das Team löschen?"
+
+#, python-format
+msgid "Team__delete__warning__text %(team)s"
+msgstr "Das Team \"%(team)s\" wirklich löschen?"
+
+msgid "Team__delete__submit"
+msgstr "Team löschen"
+
 # Use translation from core
 msgid "Team__name"
 msgstr ""
@@ -1520,6 +1533,18 @@ msgstr "Verwaltungsrechte entfernen"
 msgid "TeamMember__promote_button"
 msgstr "Verwaltungsrechte verleihen"
 
+msgid "TeamMember__remove__button"
+msgstr "Entfernen"
+
+msgid "Team__delete__title"
+msgstr "Team löschen"
+
+msgid "Team__delete__introduction"
+msgstr "Hiermit wird dieses Team gelöscht. Dies kann nicht rückgängig gemacht werden."
+
+msgid "Team__delete__button"
+msgstr "Team löschen"
+
 msgid "Team__member_count"
 msgstr "Mitgliederanzahl"
 
@@ -2069,6 +2094,9 @@ msgstr "Das Team \"%(name)s\" wurde angelegt!"
 msgid "Team__update__success %(name)s"
 msgstr "Das Aktualisieren des Teams \"%(name)s\" war erfolgreich!"
 
+msgid "Team__delete__success"
+msgstr "Das Team wurde gelöscht."
+
 msgid "Lock-gone"
 msgstr "Sperre bestand nicht mehr."
 
diff --git a/src/backoffice/locale/en/LC_MESSAGES/django.po b/src/backoffice/locale/en/LC_MESSAGES/django.po
index 1cd300fc6..71beda514 100644
--- a/src/backoffice/locale/en/LC_MESSAGES/django.po
+++ b/src/backoffice/locale/en/LC_MESSAGES/django.po
@@ -820,6 +820,9 @@ msgstr "save"
 msgid "ActivityLog_no_entries"
 msgstr "No activity log entries"
 
+msgid "Cancel"
+msgstr ""
+
 #, python-format
 msgid "Invitation__description %(requester)s %(requested)s %(type)s"
 msgstr "Invitation from \"%(requester)s\" to \"%(requested)s\" (type: %(type)s)"
@@ -1483,6 +1486,16 @@ msgstr "Save"
 msgid "Details"
 msgstr ""
 
+msgid "Team__delete__warning__header"
+msgstr "Delete the team?"
+
+#, python-format
+msgid "Team__delete__warning__text %(team)s"
+msgstr "Are you sure you want to delete the team \"%(team)s\"?"
+
+msgid "Team__delete__submit"
+msgstr "Delete team"
+
 # Use translation from core
 msgid "Team__name"
 msgstr ""
@@ -1525,6 +1538,18 @@ msgstr "Remote management rights"
 msgid "TeamMember__promote_button"
 msgstr "Add management rights"
 
+msgid "TeamMember__remove__button"
+msgstr "Remove"
+
+msgid "Team__delete__title"
+msgstr "Delete this team"
+
+msgid "Team__delete__introduction"
+msgstr "This deletes this Team. This action cannot be reversed!"
+
+msgid "Team__delete__button"
+msgstr "Delete team"
+
 msgid "Team__member_count"
 msgstr "members count"
 
@@ -2074,6 +2099,9 @@ msgstr "The team \"%(name)s\" was successfully created."
 msgid "Team__update__success %(name)s"
 msgstr "The team \"%(name)s\" was successfully updated."
 
+msgid "Team__delete__success"
+msgstr "Team was deleted."
+
 msgid "Lock-gone"
 msgstr "Lock was gone already."
 
diff --git a/src/backoffice/templates/backoffice/components/confirmation_modal.html b/src/backoffice/templates/backoffice/components/confirmation_modal.html
new file mode 100644
index 000000000..28a1098be
--- /dev/null
+++ b/src/backoffice/templates/backoffice/components/confirmation_modal.html
@@ -0,0 +1,25 @@
+{% extends "backoffice/base.html" %}
+{% load rules %}
+{% load i18n %}
+{% load static %}
+{% load hub_absolute %}
+
+{% block title %}
+  {{ confirmation_title }}
+{% endblock title %}
+{% block content %}
+  <div class="card">
+    <div class="card-header text-bg-{{ confirmation_class }}"
+         id="confirmationModalHeader">
+      <h1 class="fs-5" id="confirmationModalLabel">{{ confirmation_title }}</h1>
+    </div>
+    <div class="card-body" id="confirmationModalBody">{{ confirmation_body }}</div>
+    <form action="?confirmation=true" method="post">
+      <div class="card-footer text-end">
+        <a href="{{ confirmation_cancel_url }}" class="btn btn-secondary">{% trans "Cancel" %}</a>
+        {% csrf_token %}
+        <button type="submit" class="btn btn-{{ confirmation_class }}">{{ confirmation_submit }}</button>
+      </div>
+    </form>
+  </div>
+{% endblock content %}
diff --git a/src/backoffice/templates/backoffice/teams/detail.html b/src/backoffice/templates/backoffice/teams/detail.html
index 7071d50eb..84e857fac 100644
--- a/src/backoffice/templates/backoffice/teams/detail.html
+++ b/src/backoffice/templates/backoffice/teams/detail.html
@@ -7,10 +7,35 @@
 {% block title %}
   {% trans "Details" %} | {% trans "Team" %} | {{ team.name }}
 {% endblock title %}
-
+{% block scripts %}
+  <script src="{% static "backoffice/form-add.js" %}"></script>
+  <script src="{% static "backoffice/modal.js" %}"></script>
+  <script nonce="{{ request.csp_nonce }}">
+  $(document).ready(() => {
+    showModal = registerModal()
+    deleteSubmit = document.getElementById('TeamDeleteSubmit')
+    if(deleteSubmit) {
+      deleteSubmit.addEventListener('click', (e) => {
+        e.preventDefault();
+        showModal(
+          () => {
+            form = document.getElementById('TeamDeleteForm')
+            form.action = form.action + '?confirmation=true'
+            form.submit()
+          },
+          'danger',
+          '{% trans "Team__delete__warning__header" %}',
+          '{% blocktrans %}Team__delete__warning__text {{ team }}{% endblocktrans %}',
+          '{% trans "Team__delete__submit" %}')
+      })
+    }
+  });
+  </script>
+{% endblock scripts %}
 {% block content %}
   {% has_perm 'core.change_team' request.user team as can_change %}
   {% has_perm 'core.view_details_team' request.user team as can_view_details %}
+  {% has_perm 'core.delete_team' request.user team as can_delete %}
   <div class="card border-default">
     <div class="card-header bg-default">{% trans "Details" %} | {% trans "Team" %}</div>
     <div class="card-body">
@@ -79,6 +104,8 @@
                         <input type="hidden" name="can_manage" value="True">
                         <button type="submit" class="btn btn-sm btn-primary">{% trans "TeamMember__promote_button" %}</button>
                       {% endif %}
+                      <a href="{% url 'backoffice:team-member-delete' team=team.uuid pk=member.id %}"
+                         class="btn btn-sm btn-danger">{% trans "TeamMember__remove__button" %}</a>
                     </form>
                   </td>
                 {% endif %}
@@ -90,4 +117,25 @@
       </table>
     </div>
   </div>
+
+  {% if not form.create and can_delete %}
+    <div class="card card-danger border-danger mt-3 mb-3">
+      <div class="card-header bg-default text-danger">{% trans "Team__delete__title" %}</div>
+      <div class="card-body">
+        <p>{% trans "Team__delete__introduction" %}</p>
+      </div>
+      <div class="card-footer text-end">
+
+        <form class="form"
+              action="{% url "backoffice:team-delete" uuid=team.uuid %}"
+              method="post"
+              id="TeamDeleteForm">
+          {% csrf_token %}
+          <button type="submit" class="btn btn-sm btn-danger" id="TeamDeleteSubmit">
+            {% trans "Team__delete__button" %}: {{ object.name }}
+          </button>
+        </form>
+      </div>
+    </div>
+  {% endif %}
 {% endblock content %}
diff --git a/src/backoffice/tests/teams/__init__.py b/src/backoffice/tests/teams/__init__.py
index 3345c3b8e..e9d9b0415 100644
--- a/src/backoffice/tests/teams/__init__.py
+++ b/src/backoffice/tests/teams/__init__.py
@@ -3,6 +3,7 @@ from backoffice.tests.teams.members import (
 )
 from backoffice.tests.teams.teams import (
     TeamCreateViewTestCase,
+    TeamDeleteViewTestCase,
     TeamDetailViewTestCase,
     TeamListViewTestCase,
     TeamUpdateViewTestCase,
@@ -10,6 +11,7 @@ from backoffice.tests.teams.teams import (
 
 __all__ = (
     'TeamCreateViewTestCase',
+    'TeamDeleteViewTestCase',
     'TeamDetailViewTestCase',
     'TeamListViewTestCase',
     'TeamMemberUpdateViewTestCase',
diff --git a/src/backoffice/tests/teams/teams.py b/src/backoffice/tests/teams/teams.py
index 5dde5c403..6e0bc74cd 100644
--- a/src/backoffice/tests/teams/teams.py
+++ b/src/backoffice/tests/teams/teams.py
@@ -95,6 +95,7 @@ class TeamDetailViewTestCase(BackOfficeTestCase):
         self.assertTemplateUsed(response, 'backoffice/teams/detail.html')
         self.assertEqual(self.team, response.context['team'])
         self.assertContains(response, _('Team__edit__button'))
+        self.assertContains(response, _('Team__delete__button'))
 
     def test_team_detail_staff(self):
         self.client.force_login(self.staff)
@@ -103,6 +104,7 @@ class TeamDetailViewTestCase(BackOfficeTestCase):
         self.assertTemplateUsed(response, 'backoffice/teams/detail.html')
         self.assertEqual(self.team, response.context['team'])
         self.assertContains(response, _('Team__edit__button'))
+        self.assertNotContains(response, _('Team__delete__button'))
 
     def test_team_detail_team_member(self):
         self.client.force_login(self.user)
@@ -111,6 +113,7 @@ class TeamDetailViewTestCase(BackOfficeTestCase):
         self.assertTemplateUsed(response, 'backoffice/teams/detail.html')
         self.assertEqual(self.team, response.context['team'])
         self.assertNotContains(response, _('Team__edit__button'))
+        self.assertNotContains(response, _('Team__delete__button'))
 
     def test_team_detail_non_team_member(self):
         self.client.force_login(self.user_2)
@@ -119,6 +122,7 @@ class TeamDetailViewTestCase(BackOfficeTestCase):
         self.assertTemplateUsed(response, 'backoffice/teams/detail.html')
         self.assertEqual(self.team, response.context['team'])
         self.assertNotContains(response, _('Team__edit__button'))
+        self.assertNotContains(response, _('Team__delete__button'))
 
 
 class TeamCreateViewTestCase(BackOfficeTestCase):
@@ -208,3 +212,50 @@ class TeamUpdateViewTestCase(BackOfficeTestCase):
         self.client.force_login(self.user)
         response = self.client.get(reverse('backoffice:team-edit', kwargs={'uuid': self.team.uuid}))
         self.assertEqual(response.status_code, 403)
+
+
+class TeamDeleteViewTestCase(BackOfficeTestCase):
+    def setUp(self):
+        super().setUp()
+        self.team = Team.objects.create(
+            name='team',
+            conference=self.conf,
+        )
+        self.team_member = TeamMember.objects.create(team=self.team, user=self.admin)
+        self.team_member2 = TeamMember.objects.create(team=self.team, user=self.staff, can_manage=True)
+        self.team_member3 = TeamMember.objects.create(team=self.team, user=self.user)
+
+    def test_team_delete_unauthenicated(self):
+        activate('en')
+        response = self.client.get(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+        self.assertRedirects(response, reverse('backoffice:login') + '?next=' + reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+
+    def test_team_delete_admin(self):
+        self.client.force_login(self.admin)
+        response = self.client.get(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html')
+        self.assertContains(response, _('Team__delete__warning__header'))
+        self.assertContains(response, _('Team__delete__submit'))
+
+        response = self.client.post(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+        # No confirmation, should not delete
+        self.assertTrue(Team.objects.filter(name='team').exists())
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/components/confirmation_modal.html')
+        self.assertContains(response, _('Team__delete__warning__header'))
+        self.assertContains(response, _('Team__delete__submit'))
+
+        response = self.client.post(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}), data={'confirmation': 'true'})
+        self.assertFalse(Team.objects.filter(name='team').exists())
+        self.assertRedirects(response, reverse('backoffice:teams'))
+
+    def test_team_delete_staff(self):
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 403)
+
+    def test_team_delete_team_member(self):
+        self.client.force_login(self.user)
+        response = self.client.get(reverse('backoffice:team-delete', kwargs={'uuid': self.team.uuid}))
+        self.assertEqual(response.status_code, 403)
diff --git a/src/backoffice/views/teams/__init__.py b/src/backoffice/views/teams/__init__.py
index 3dca46fa1..6d5a45337 100644
--- a/src/backoffice/views/teams/__init__.py
+++ b/src/backoffice/views/teams/__init__.py
@@ -3,6 +3,7 @@ from backoffice.views.teams.members import (
 )
 from backoffice.views.teams.teams import (
     TeamCreateView,
+    TeamDeleteView,
     TeamDetailView,
     TeamListView,
     TeamUpdateView,
@@ -10,6 +11,7 @@ from backoffice.views.teams.teams import (
 
 __all__ = [
     'TeamCreateView',
+    'TeamDeleteView',
     'TeamDetailView',
     'TeamListView',
     'TeamMemberUpdateView',
diff --git a/src/backoffice/views/teams/teams.py b/src/backoffice/views/teams/teams.py
index ac35c7cd6..6e185c487 100644
--- a/src/backoffice/views/teams/teams.py
+++ b/src/backoffice/views/teams/teams.py
@@ -3,10 +3,11 @@ from typing import Any
 from django.contrib.postgres.aggregates.general import ArrayAgg
 from django.db.models import BooleanField, Case, Count, When
 from django.db.models.query import Q, QuerySet
-from django.urls import reverse
+from django.http import HttpRequest, HttpResponse
+from django.urls import reverse, reverse_lazy
 from django.utils.translation import gettext as _
 from django.views.generic import DetailView, FormView, ListView
-from django.views.generic.edit import CreateView, UpdateView
+from django.views.generic.edit import CreateView, DeleteView, UpdateView
 from rules.contrib.views import AutoPermissionRequiredMixin
 
 from core.forms import TeamForm
@@ -123,3 +124,25 @@ class TeamCreateView(TeamFormMixin, CreateView):
 
 class TeamUpdateView(SingleUUIDObjectMixin, TeamFormMixin, UpdateView):
     success_message = _('Team__update__success %(name)s')
+
+
+class TeamDeleteView(SingleUUIDObjectMixin, FormMesssageMixin, TeamNavContextMixin, AutoPermissionRequiredMixin, DeleteView):
+    model = Team
+    template_name = 'backoffice/components/confirmation_modal.html'
+    success_url = reverse_lazy('backoffice:teams')
+    success_message = _('Team__delete__success')
+
+    def post(self, request: HttpRequest, *args: str, **kwargs: Any) -> HttpResponse:
+        if request.POST.get('confirmation', request.GET.get('confirmation', 'false')) != 'true':
+            return self.get(request, *args, **kwargs)
+        return super().post(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs) -> dict[str, Any]:
+        return {
+            **super().get_context_data(**kwargs),
+            'confirmation_title': _('Team__delete__warning__header'),
+            'confirmation_body': _('Team__delete__warning__text %(team)s') % {'team': self.object.name},
+            'confirmation_class': 'danger',
+            'confirmation_submit': _('Team__delete__submit'),
+            'confirmation_cancel_url': reverse('backoffice:team', kwargs={'uuid': self.object.uuid}),
+        }
-- 
GitLab