Skip to content
3 changes: 3 additions & 0 deletions cms/auth_content/exporters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from cms.auth_content.exporters.user_exporter import (
generate_user_permission_sets_csv_rows,
)
23 changes: 23 additions & 0 deletions cms/auth_content/exporters/user_exporter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import csv


class Echo:
@staticmethod
def write(value):
return value


def generate_user_permission_sets_csv_rows(users):
writer = csv.writer(Echo())
yield writer.writerow(
["User ID", "Permission Set", "Gives Global Access", "Permissions"]
)
for user in users:
permission_sets = user.permission_sets.all()
if permission_sets:
for ps in permission_sets:
yield writer.writerow(
[user.user_id, ps.display_name, ps.global_access, ps.name]
)
else:
yield writer.writerow([user.user_id, "", "", ""])
4 changes: 4 additions & 0 deletions cms/auth_content/models/permission_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ def permission_set_details(self):
parts = [part.strip() for part in self.name.split("|")]
return mark_safe("<br>".join(parts))

@property
def global_access(self):
return self.theme == "-1" and self.geography_type == "-1"

panels = [
FieldPanel("display_name"),
FieldPanel("theme"),
Expand Down
38 changes: 38 additions & 0 deletions cms/auth_content/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import logging
from datetime import datetime

from django.http import StreamingHttpResponse
from django.views.decorators.http import require_http_methods

from cms.auth_content.exporters import generate_user_permission_sets_csv_rows
from cms.auth_content.models.users import User

audit_logger = logging.getLogger("audit")


@require_http_methods(["GET"])
def export_user_permission_sets_csv(request):
user_id = (
request.user.id
if request.user and request.user.is_authenticated
else "anonymous"
)
audit_logger.info(
"User permission sets exported to CSV",
extra={
"user": user_id,
"action": "CSV EXPORT",
"target": "Users and permissions",
},
)

users = User.objects.with_permission_sets()
response = StreamingHttpResponse(
generate_user_permission_sets_csv_rows(users),
content_type="text/csv",
)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
response["Content-Disposition"] = (
f'attachment; filename="dashboard_cms_users_{timestamp}.csv"'
)
return response
31 changes: 31 additions & 0 deletions cms/auth_content/wagtail_hooks.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from django.urls import path, reverse
from django.utils.functional import cached_property
from wagtail import hooks
from wagtail.admin.views.generic import IndexView
from wagtail.admin.viewsets.model import (
ModelPermissionPolicy,
ModelViewSet,
ModelViewSetGroup,
)
from wagtail.admin.widgets import Button

from cms.auth_content.models.api_application import APIApplication
from cms.auth_content.models.permission_sets import PermissionSet
from cms.auth_content.models.users import User
from cms.auth_content.views import export_user_permission_sets_csv


class NoEditPermissionPolicy(ModelPermissionPolicy):
Expand All @@ -31,11 +36,26 @@ class PermissionSetViewSet(ModelViewSet):
inspect_view_fields = ["permission_set_details"]


class UserIndexView(IndexView):
@cached_property
def header_more_buttons(self):
buttons = super().header_more_buttons
buttons.append(
Button(
"Export to CSV",
reverse("export_user_permission_sets_csv"),
)
)
return buttons


class UserViewSet(ModelViewSet):
model = User
menu_label = "Users"
icon = "user"

index_view_class = UserIndexView


class APIApplicationViewSet(ModelViewSet):
model = APIApplication
Expand All @@ -53,3 +73,14 @@ class AuthGroup(ModelViewSetGroup):
@hooks.register("register_admin_viewset")
def register_auth_viewset():
return AuthGroup()


@hooks.register("register_admin_urls")
def register_user_export_url():
return [
path(
"user/export-csv/",
export_user_permission_sets_csv,
name="export_user_permission_sets_csv",
),
]
6 changes: 6 additions & 0 deletions metrics/data/managers/rbac_models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,9 @@ def get_permission_sets_for_user(user_id: UUID) -> models.QuerySet:
5
"""
return PermissionSet.objects.filter(user__user_id=user_id)

def with_permission_sets(self):
"""
Gets all users and their assigned permission sets, ordering by user id.
"""
return self.prefetch_related("permission_sets").order_by("user_id")
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import uuid
from django.test import TestCase

from cms.auth_content.models.users import User
from cms.auth_content.models.permission_sets import PermissionSet
from cms.auth_content.exporters.user_exporter import (
generate_user_permission_sets_csv_rows,
)


class TestUserCsvExporter(TestCase):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potentially add a test for no users to test the CSV headers generation? Or is this suitably covered by checking

self.assertEqual(
            rows[0], "User ID,Permission Set,Gives Global Access,Permissions\r\n"
        )

every time?

def test_user_with_no_permission_sets_exports_as_blank_row(self):
user = User.objects.create(user_id=uuid.uuid4())

rows = list(generate_user_permission_sets_csv_rows(User.objects.all()))

self.assertEqual(len(rows), 2)
self.assertEqual(
rows[0], "User ID,Permission Set,Gives Global Access,Permissions\r\n"
)
self.assertEqual(rows[1], f"{str(user.user_id)},,,\r\n")

def test_export_with_multiple_users_and_permissions(self):
# Results are sorted by user id, so hardcoding easier to determine uuids to ensure test order
user_without_permissions = User.objects.create(
user_id="11111111-1111-1111-1111-111111111111"
)
user_with_one_permission_set = User.objects.create(
user_id="22222222-2222-2222-2222-222222222222"
)
user_with_two_permission_sets = User.objects.create(
user_id="33333333-3333-3333-3333-333333333333"
)
global_access_permission_set = PermissionSet.objects.create(
display_name="Global",
theme="-1",
sub_theme="-1",
topic="-1",
metric="-1",
geography_type="-1",
)
limited_access_permission_set = PermissionSet.objects.create(
display_name="Limited",
theme="11",
sub_theme="-1",
topic="-1",
metric="-1",
geography_type="-1",
)
user_with_one_permission_set.permission_sets.add(limited_access_permission_set)
user_with_two_permission_sets.permission_sets.add(
global_access_permission_set, limited_access_permission_set
)
rows = list(generate_user_permission_sets_csv_rows(User.objects.all()))

self.assertEqual(len(rows), 5)
self.assertEqual(
rows[0], "User ID,Permission Set,Gives Global Access,Permissions\r\n"
)
self.assertEqual(rows[1], f"{str(user_without_permissions.user_id)},,,\r\n")
self.assertEqual(
rows[2],
f"{str(user_with_one_permission_set.user_id)},Limited,False,Theme: 11 | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All)\r\n",
)
self.assertEqual(
rows[3],
f"{str(user_with_two_permission_sets.user_id)},Global,True,Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All)\r\n",
)
self.assertEqual(
rows[4],
f"{str(user_with_two_permission_sets.user_id)},Limited,False,Theme: 11 | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All)\r\n",
)
59 changes: 59 additions & 0 deletions tests/integration/cms/auth_content/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import freezegun
from unittest import mock
import uuid
from django.test import TestCase
from django.urls import reverse

from cms.auth_content.models.users import User


class TestExportUserPermissionsView(TestCase):
def setUp(self):
self.mock_logger = mock.patch("cms.auth_content.views.audit_logger").start()
self.addCleanup(mock.patch.stopall)
self.superuser = self._create_superuser()
self.client.force_login(self.superuser)

@staticmethod
def _create_superuser():
from django.contrib.auth import get_user_model

AdminUser = get_user_model()
return AdminUser.objects.create_superuser(
username="admin", email="admin@example.com", password="password"
)

def test_export_requires_login(self):
self.client.logout()
url = reverse("export_user_permission_sets_csv")

response = self.client.get(url)

self.assertEqual(response.status_code, 302)

@freezegun.freeze_time("2026-08-17 12:00:00")
def test_export_user_permissions_view_response(self):
User.objects.create(user_id=uuid.uuid4())
url = reverse("export_user_permission_sets_csv")

response = self.client.get(url)

self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/csv")
self.assertIn(
'attachment; filename="dashboard_cms_users_20260817-120000.csv"',
response["Content-Disposition"],
)

def test_exporting_users_creates_audit_log(self):
User.objects.create(user_id=uuid.uuid4())
url = reverse("export_user_permission_sets_csv")

response = self.client.get(url)

self.assertEqual(response.status_code, 200)
self.mock_logger.info.assert_called_once()
_, kwargs = self.mock_logger.info.call_args
self.assertEqual(kwargs["extra"]["user"], self.superuser.id)
self.assertEqual(kwargs["extra"]["action"], "CSV EXPORT")
self.assertEqual(kwargs["extra"]["target"], "Users and permissions")
Empty file.
15 changes: 15 additions & 0 deletions tests/unit/auth_content/models/test_permission_sets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from cms.auth_content.models.permission_sets import PermissionSet


class TestPermissionSet:
def test_global_access_property_with_global_access(self):
permission_set = PermissionSet()
permission_set.theme = "-1"
permission_set.geography_type = "-1"
assert permission_set.global_access == True

def test_global_access_property_without_global_access(self):
permission_set = PermissionSet()
permission_set.theme = "-1"
permission_set.geography_type = "1"
assert permission_set.global_access == False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a question - where does the combination of theme and geography_type come from? To determine global access or not

9 changes: 9 additions & 0 deletions tests/unit/cms/auth_content/test_wagtail_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
NoEditPermissionPolicy,
PermissionSetViewSet,
AuthGroup,
UserIndexView,
register_auth_viewset,
)

Expand All @@ -20,6 +21,14 @@ def test_register_auth_viewset(self):
assert len(result.items) == 3


class TestUserIndexView(TestCase):
def test_header_more_buttons(self):
view = UserIndexView()
assert len(view.header_more_buttons) == 1
assert view.header_more_buttons[0].label == "Export to CSV"
assert view.header_more_buttons[0].url == "/cms-admin/user/export-csv/"


class TestPermissionSetDetailsProperty(TestCase):

def test_single_value_no_pipe(self):
Expand Down
Loading