From 4a1ace361013f00598fe0f0258c4311c4654a1b3 Mon Sep 17 00:00:00 2001
From: Lucas Brandstaetter <lucas@brandstaetter.tech>
Date: Sun, 8 Dec 2024 23:59:19 +0100
Subject: [PATCH] Add teams list view

---
 .../locale/de/LC_MESSAGES/django.po           | 24 +++++++
 .../locale/en/LC_MESSAGES/django.po           | 24 +++++++
 .../templates/backoffice/teams/list.html      | 69 +++++++++++++++++++
 src/backoffice/tests/__init__.py              |  1 +
 src/backoffice/tests/teams/__init__.py        |  3 +
 src/backoffice/tests/teams/teams.py           | 66 ++++++++++++++++++
 src/backoffice/urls.py                        |  2 +
 src/backoffice/views/teams/__init__.py        |  7 ++
 src/backoffice/views/teams/teams.py           | 36 ++++++++++
 9 files changed, 232 insertions(+)
 create mode 100644 src/backoffice/templates/backoffice/teams/list.html
 create mode 100644 src/backoffice/tests/teams/__init__.py
 create mode 100644 src/backoffice/tests/teams/teams.py
 create mode 100644 src/backoffice/views/teams/__init__.py
 create mode 100644 src/backoffice/views/teams/teams.py

diff --git a/src/backoffice/locale/de/LC_MESSAGES/django.po b/src/backoffice/locale/de/LC_MESSAGES/django.po
index 68c1394bb..2663d40fe 100644
--- a/src/backoffice/locale/de/LC_MESSAGES/django.po
+++ b/src/backoffice/locale/de/LC_MESSAGES/django.po
@@ -1443,6 +1443,30 @@ msgstr "Self-organized Session löschen"
 msgid "SoS__delete__introduction"
 msgstr "Hiermit wird diese Self-organized Session gelöscht. Wenn du sie nur unsichtbar machen möchtest gibt es weiter oben die Möglichkeit die Self-organized Session zurückzunehmen!"
 
+# use translation from core
+msgid "Teams"
+msgstr ""
+
+msgid "Team__name"
+msgstr "Name"
+
+# Use translation from core
+msgid "Team__require_staff"
+msgstr ""
+
+msgid "Team__member_count"
+msgstr "Mitgliederanzahl"
+
+# Use translation from core
+msgid "TeamMember__can_manage"
+msgstr ""
+
+msgid "Team__list__not_a_member"
+msgstr "Kein Mitglied"
+
+msgid "Team__create__button"
+msgstr "Team anlegen"
+
 msgid "data_table__info__no_data"
 msgstr "Bisher keine Daten vorhanden"
 
diff --git a/src/backoffice/locale/en/LC_MESSAGES/django.po b/src/backoffice/locale/en/LC_MESSAGES/django.po
index 1d48e35e4..fda5d873c 100644
--- a/src/backoffice/locale/en/LC_MESSAGES/django.po
+++ b/src/backoffice/locale/en/LC_MESSAGES/django.po
@@ -1448,6 +1448,30 @@ msgstr "Delete self-organized session"
 msgid "SoS__delete__introduction"
 msgstr "This removes this self-organized session. If you only want to make it invisible, there is an option above to recall the self-organized session above!"
 
+# use translation from core
+msgid "Teams"
+msgstr ""
+
+msgid "Team__name"
+msgstr "name"
+
+# Use translation from core
+msgid "Team__require_staff"
+msgstr ""
+
+msgid "Team__member_count"
+msgstr "members count"
+
+# Use translation from core
+msgid "TeamMember__can_manage"
+msgstr ""
+
+msgid "Team__list__not_a_member"
+msgstr "Not a member"
+
+msgid "Team__create__button"
+msgstr "Create team"
+
 msgid "data_table__info__no_data"
 msgstr "No data available in table"
 
diff --git a/src/backoffice/templates/backoffice/teams/list.html b/src/backoffice/templates/backoffice/teams/list.html
new file mode 100644
index 000000000..a42244d76
--- /dev/null
+++ b/src/backoffice/templates/backoffice/teams/list.html
@@ -0,0 +1,69 @@
+{% extends "backoffice/base.html" %}
+{% load rules %}
+{% load i18n %}
+{% load static %}
+
+{% block htmlhead %}
+  <link rel="stylesheet"
+        href="{% static 'vendor/datatables/datatables.min.css' %}">
+{% endblock htmlhead %}
+
+{% block scripts %}
+  <script src="{% static 'vendor/datatables/datatables.min.js' %}"></script>
+  <script nonce="{{ request.csp_nonce }}">{% include "backoffice/components/list_script.js" with table_id='teams' %}</script>
+{% endblock scripts %}
+
+{% block content %}
+  {% has_perm 'books.add_book' request.user as can_add_team %}
+  <div class="card">
+    <div class="card-header">
+      <span class="text-muted">{% trans "Teams" %}:</span> {{ mode_display }}
+    </div>
+    <div class="card-body">
+
+      {% if filter_tag is not None %}
+        <div class="alert alert-info">
+          {% trans "assemblies_filtered_tag" %}: <strong>{{ filter_tag }}</strong>
+        </div>
+      {% endif %}
+
+      <table class="table table-sm" id="teams">
+        <thead>
+          <tr>
+            <th>{% trans "Team__name" %}</th>
+            <th>{% trans "Team__require_staff" %}</th>
+            {% if user.is_staff %}
+              <th>{% trans "Team__member_count" %}</th>
+            {% endif %}
+            <th>{% trans "TeamMember__can_manage" %}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for team in object_list %}
+            <tr>
+              <td>
+                <a href="{% url 'backoffice:team' uuid=team.uuid %}">{{ team.name }}</a>
+              </td>
+              <td>{{ team.require_staff|yesno }}</td>
+              {% if user.is_staff %}<td>{{ team.members_count }}</td>{% endif %}
+              <td>
+                {% if user.id in team.member_list %}
+                  {{ team.can_manage|yesno }}
+                {% else %}
+                  {% trans "Team__list__not_a_member" %}
+                {% endif %}
+              </td>
+            </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+
+    </div>
+    {% if can_add_team %}
+      <div class="card-footer text-end">
+        <a href="{% url 'backoffice:team-create' %}"
+           class="btn btn-primary btn-sm">{% trans "Team__create__button" %}</a>
+      </div>
+    {% endif %}
+  </div>
+{% endblock content %}
diff --git a/src/backoffice/tests/__init__.py b/src/backoffice/tests/__init__.py
index 5cb8812d2..92713085f 100644
--- a/src/backoffice/tests/__init__.py
+++ b/src/backoffice/tests/__init__.py
@@ -2,5 +2,6 @@ from .base import *  # noqa: F401, F403, I001
 from .assemblies import *  # noqa: F401, F403
 from .auth import *  # noqa: F401, F403
 from .invitations import *  # noqa: F401, F403
+from .teams import *  # noqa: F401, F403
 
 __all__ = ('*',)  # noqa: F405
diff --git a/src/backoffice/tests/teams/__init__.py b/src/backoffice/tests/teams/__init__.py
new file mode 100644
index 000000000..db9ed6730
--- /dev/null
+++ b/src/backoffice/tests/teams/__init__.py
@@ -0,0 +1,3 @@
+from backoffice.tests.teams.teams import TeamListViewTestCase
+
+__all__ = ('TeamListViewTestCase',)
diff --git a/src/backoffice/tests/teams/teams.py b/src/backoffice/tests/teams/teams.py
new file mode 100644
index 000000000..ecdfd789e
--- /dev/null
+++ b/src/backoffice/tests/teams/teams.py
@@ -0,0 +1,66 @@
+from unittest.mock import patch
+
+from django.urls import reverse
+from django.utils.translation import activate
+from django.utils.translation import gettext as _
+
+from core.models import (
+    Team,
+    TeamMember,
+)
+from core.tests.mock import mocktrans
+
+from backoffice.tests.base import BackOfficeTestCase
+
+
+class TeamListViewTestCase(BackOfficeTestCase):
+    def setUp(self):
+        super().setUp()
+        self.teams = {}
+        for team in ['team1', 'team2', 'team3']:
+            self.teams[team] = Team.objects.create(
+                name=team,
+                conference=self.conf,
+            )
+        self.teams['team1'].require_staff = True
+        TeamMember.objects.create(team=self.teams['team1'], user=self.staff)
+        TeamMember.objects.create(team=self.teams['team1'], user=self.admin, can_manage=True)
+        TeamMember.objects.create(team=self.teams['team2'], user=self.staff, can_manage=True)
+        TeamMember.objects.create(team=self.teams['team3'], user=self.admin)
+
+    def test_team_list_unauthenticated(self):
+        activate('en')
+        response = self.client.get(reverse('backoffice:teams'))
+        self.assertRedirects(response, reverse('backoffice:login') + '?next=' + reverse('backoffice:teams'))
+
+    def test_team_list_admin(self):
+        self.client.force_login(self.admin)
+        with patch('backoffice.views.teams.teams._', mocktrans):
+            response = self.client.get(reverse('backoffice:teams'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/list.html')
+        self.assertQuerySetEqual(Team.objects.all().order_by('name'), response.context['teams'])
+        for team in self.teams:
+            self.assertContains(response, team)
+        self.assertContains(response, _('Team__create__button'))
+
+    def test_team_list_staff(self):
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse('backoffice:teams'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/list.html')
+        self.assertQuerySetEqual(Team.objects.all().order_by('name'), response.context['teams'])
+        for team in self.teams:
+            self.assertContains(response, team)
+        self.assertNotContains(response, _('Team__create__button'))
+
+    def test_team_list_user(self):
+        self.client.force_login(self.user)
+        with patch('backoffice.views.teams.teams._', mocktrans):
+            response = self.client.get(reverse('backoffice:teams'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'backoffice/teams/list.html')
+        self.assertQuerySetEqual(Team.objects.filter(require_staff=False).order_by('name'), response.context['teams'])
+        for team in self.teams:
+            self.assertContains(response, team)
+        self.assertNotContains(response, _('Team__create__button'))
diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py
index accf9e23b..cf888bac6 100644
--- a/src/backoffice/urls.py
+++ b/src/backoffice/urls.py
@@ -13,6 +13,7 @@ from backoffice.views import (
     profile,
     projects,
     schedules,
+    teams,
     vouchers,
     wiki,
 )
@@ -152,6 +153,7 @@ urlpatterns = [
     path('self-organized/sessions/<uuid:pk>/delete', events.SoSDeleteView.as_view(), name='sos-delete'),
     path('sos/new', RedirectView.as_view(pattern_name='backoffice:sos-create')),
     path('sos/<uuid:pk>/', RedirectView.as_view(pattern_name='backoffice:sos-edit')),
+    path('teams', teams.TeamListView.as_view(), name='teams'),
     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
new file mode 100644
index 000000000..a5dc575b1
--- /dev/null
+++ b/src/backoffice/views/teams/__init__.py
@@ -0,0 +1,7 @@
+from backoffice.views.teams.teams import (
+    TeamListView,
+)
+
+__all__ = [
+    'TeamListView',
+]
diff --git a/src/backoffice/views/teams/teams.py b/src/backoffice/views/teams/teams.py
new file mode 100644
index 000000000..82af22e2d
--- /dev/null
+++ b/src/backoffice/views/teams/teams.py
@@ -0,0 +1,36 @@
+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.views.generic import ListView
+
+from core.models import ConferenceMember, Team
+
+from backoffice.views.mixins import ConferenceRuleLoginRequiredMixin
+
+
+class TeamListView(ConferenceRuleLoginRequiredMixin, ListView):
+    model = Team
+    template_name = 'backoffice/teams/list.html'
+    context_object_name = 'teams'
+    permission_required = 'core.view_team'
+
+    def get_queryset(self) -> QuerySet[Team]:
+        member = ConferenceMember.get_member(
+            conference=self.conference,
+            user=self.request.user,
+        )
+        qs = super().get_queryset().order_by('name')
+        if member.is_staff:
+            qs = qs.annotate(
+                members_count=Count('members'),
+            )
+        else:
+            qs = qs.filter(require_staff=False)
+        return qs.annotate(
+            member_list=ArrayAgg('members__user'),
+            self_manage_count=Count('members', filter=Q(members__user=self.request.user, members__can_manage=True)),
+        ).annotate(
+            can_manage=Case(When(self_manage_count__gt=0, then=True), default=False, output_field=BooleanField()),
+        )
-- 
GitLab