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.

+ +
+ {{ csrf_input }} + +
{% 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}))