From 45a0b1ff252dc22c4e8df05f11d3ff15a56bc600 Mon Sep 17 00:00:00 2001
From: James Stott <158563996+jamesstottmoj@users.noreply.github.com>
Date: Thu, 7 Nov 2024 12:04:48 +0000
Subject: [PATCH] Added button to export admin list as CSV (#1381)
* Added button to export admin list as CSV
* Removed un-needed code
* Moved export button to top of page. Set timestamp as part of filename. Now have a line per app rather than per user
* Fixed broken test
* Bumped dependencies
---
.../frontend/jinja2/webapp-admin-list.html | 21 ++++++----
controlpanel/frontend/urls.py | 1 +
controlpanel/frontend/views/__init__.py | 1 +
controlpanel/frontend/views/app.py | 42 +++++++++++++++++--
requirements.dev.txt | 2 +-
requirements.txt | 4 +-
tests/frontend/views/test_app.py | 15 +++++++
7 files changed, 71 insertions(+), 15 deletions(-)
diff --git a/controlpanel/frontend/jinja2/webapp-admin-list.html b/controlpanel/frontend/jinja2/webapp-admin-list.html
index 3e5491c6f..07f197ea8 100644
--- a/controlpanel/frontend/jinja2/webapp-admin-list.html
+++ b/controlpanel/frontend/jinja2/webapp-admin-list.html
@@ -7,17 +7,20 @@
{% block content %}
{% set num_apps = apps|length %}
-
{{ page_title }}
+ {{ page_title }}
+
+ Export app admins
+ Export a list of app admins to a CSV file.
+
+
{% if request.user.has_perm('api.list_app') %}
{{ app_list(apps, request.user) }}
-
- {% if request.user.has_perm('api.create_app') %}
-
-
- Register an app
-
-
- {% endif %}
{% endif %}
{% endblock %}
diff --git a/controlpanel/frontend/urls.py b/controlpanel/frontend/urls.py
index f77b4f177..13e6de562 100644
--- a/controlpanel/frontend/urls.py
+++ b/controlpanel/frontend/urls.py
@@ -88,6 +88,7 @@
path("webapp-data/", views.WebappBucketList.as_view(), name="list-webapp-datasources"),
path("webapps/", views.AppList.as_view(), name="list-apps"),
path("webapps/all/", views.AdminAppList.as_view(), name="list-all-apps"),
+ path("webapps/all/admin-csv", views.AppAdminCSV.as_view(), name="app-admin-csv"),
path("webapps/new/", views.CreateApp.as_view(), name="create-app"),
path("webapps//", views.AppDetail.as_view(), name="manage-app"),
path("webapps//delete/", views.DeleteApp.as_view(), name="delete-app"),
diff --git a/controlpanel/frontend/views/__init__.py b/controlpanel/frontend/views/__init__.py
index b9971db24..99c31075a 100644
--- a/controlpanel/frontend/views/__init__.py
+++ b/controlpanel/frontend/views/__init__.py
@@ -14,6 +14,7 @@
AddAdmin,
AddCustomers,
AdminAppList,
+ AppAdminCSV,
AppDetail,
AppList,
CreateApp,
diff --git a/controlpanel/frontend/views/app.py b/controlpanel/frontend/views/app.py
index 48342bb50..56ace1dab 100644
--- a/controlpanel/frontend/views/app.py
+++ b/controlpanel/frontend/views/app.py
@@ -1,4 +1,6 @@
# Standard library
+import csv
+from datetime import datetime
from typing import List
# Third-party
@@ -8,13 +10,15 @@
from auth0.rest import Auth0Error
from django.conf import settings
from django.contrib import messages
-from django.db.models import Prefetch
-from django.http import HttpResponseRedirect
+from django.contrib.postgres.aggregates import StringAgg
+from django.db.models import F, Prefetch
+from django.db.models.functions import Coalesce
+from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.template.defaultfilters import pluralize
from django.urls import reverse_lazy
from django.utils.http import urlencode
-from django.views.generic.base import RedirectView
+from django.views.generic.base import RedirectView, View
from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, FormMixin, UpdateView
from django.views.generic.list import ListView
@@ -74,6 +78,38 @@ def get_queryset(self):
)
+class AppAdminCSV(OIDCLoginRequiredMixin, PermissionRequiredMixin, View):
+ permission_required = "api.is_superuser"
+
+ def post(self, request, *args, **kwargs):
+ apps = (
+ App.objects.filter(userapps__is_admin=True)
+ .annotate(
+ users=StringAgg("userapps__user__username", delimiter=", "),
+ emails=StringAgg(
+ Coalesce("userapps__user__justice_email", "userapps__user__email"),
+ delimiter=", ",
+ ),
+ )
+ .values("name", "users", "emails")
+ .order_by("name")
+ )
+
+ timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
+
+ response = HttpResponse(
+ content_type="text/csv",
+ headers={"Content-Disposition": f'attachment; filename="app_admins_{timestamp}.csv"'},
+ )
+
+ writer = csv.writer(response)
+ writer.writerow(["App Name", "Admins", "Emails"])
+ for app in apps:
+ writer.writerow([app["name"], app["users"], app["emails"]])
+
+ return response
+
+
class AppDetail(OIDCLoginRequiredMixin, PermissionRequiredMixin, DetailView):
context_object_name = "app"
model = App
diff --git a/requirements.dev.txt b/requirements.dev.txt
index 7a11aef69..4bba247fb 100644
--- a/requirements.dev.txt
+++ b/requirements.dev.txt
@@ -2,7 +2,7 @@ black==24.10.0
django-debug-toolbar==4.4.6
flake8==7.0.0
ipdb==0.13.13
-ipython==8.27.0
+ipython==8.29.0
isort==5.13.2
pandas==2.2.0
pre-commit==3.7.1
diff --git a/requirements.txt b/requirements.txt
index d74dd033d..5a579bc70 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,7 +5,7 @@ beautifulsoup4==4.12.3
boto3==1.35.39
botocore==1.35.44 # noqa temporarily pinning this to try to resolve an issue with failing tests
celery[sqs]==5.3.6
-channels==4.0.0
+channels==4.1.0
channels-redis==4.2.0
daphne==4.1.2
Django==5.1.2
@@ -34,7 +34,7 @@ python-dotenv==1.0.1
python-jose==3.3.0
pyyaml==6.0.1
rules==3.3
-sentry-sdk==2.5.1
+sentry-sdk==2.17.0
slackclient==2.9.4
urllib3==2.2.3
uvicorn[standard]==0.28.0
diff --git a/tests/frontend/views/test_app.py b/tests/frontend/views/test_app.py
index e04d11e46..99a263cb7 100644
--- a/tests/frontend/views/test_app.py
+++ b/tests/frontend/views/test_app.py
@@ -197,6 +197,10 @@ def list_all(client, *args):
return client.get(reverse("list-all-apps"))
+def admin_csv(client, app, *args):
+ return client.post(reverse("app-admin-csv"))
+
+
def detail(client, app, *args):
return client.get(reverse("manage-app", kwargs={"pk": app.id}))
@@ -299,6 +303,9 @@ def set_textract(client, app, *args):
(list_all, "superuser", status.HTTP_200_OK),
(list_all, "app_admin", status.HTTP_403_FORBIDDEN),
(list_all, "normal_user", status.HTTP_403_FORBIDDEN),
+ (admin_csv, "superuser", status.HTTP_200_OK),
+ (admin_csv, "app_admin", status.HTTP_403_FORBIDDEN),
+ (admin_csv, "normal_user", status.HTTP_403_FORBIDDEN),
(detail, "superuser", status.HTTP_200_OK),
(detail, "app_admin", status.HTTP_200_OK),
(detail, "normal_user", status.HTTP_403_FORBIDDEN),
@@ -352,6 +359,14 @@ def test_permissions(
assert response.status_code == expected_status
+def test_admin_csv(client, app, users):
+ client.force_login(users["superuser"])
+ response = admin_csv(client, app)
+ content = response.content.decode("utf-8")
+ assert "App Name,Admins,Emails" in content
+ assert app.name in content
+
+
def disconnect_bucket(client, apps3bucket, *args, **kwargs):
return client.post(reverse("revoke-app-access", kwargs={"pk": apps3bucket.id}))