From 7969d665f53f3bd31654b1a2232b788fce979dad Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Mon, 18 May 2026 19:51:19 -0700 Subject: [PATCH] feat(admin): filter and export admin users for Kolibri-usage signals Adds four new filters to the admin Users page based on signals of likely Kolibri usage (Slack conversation with Laura): published a channel, made Studio edits, joined recently, active recently. Adds a Download CSV action that streams the filtered user list as CSV, including registration information (locations, storage needed, source). Backend filters share Exists() expressions between AdminUserFilter and the CSV action's annotate() call. The CSV endpoint uses AdminUserCSVFilter (a RequiredFilterSet subclass) so unfiltered exports return 412. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../administration/pages/Users/UserTable.vue | 187 +++++++++++++++++ .../pages/Users/__tests__/userTable.spec.js | 82 ++++++++ .../tests/viewsets/test_user.py | 193 ++++++++++++++++++ .../contentcuration/viewsets/user.py | 188 ++++++++++++++++- 4 files changed, 649 insertions(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue index b99234b265..c409ddd82b 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue @@ -11,6 +11,15 @@ :text="`Email ${$formatNumber(count)} ${count === 1 ? 'user' : 'users'}`" @click="showMassEmailDialog = true" /> + + + + + + + + + + + + + + + filter.value.value || 'any', + set: value => { + filter.value = options.value.find(o => o.value === value) || {}; + }, + }); + return { filter: wrapped, options, fetchQueryParams }; + } + + function useBooleanFilter({ name, label, paramName }) { + const filterMap = { + no: { label: 'Any', params: {} }, + yes: { label, params: { [paramName]: true } }, + }; + const { filter, options, fetchQueryParams } = useFilter({ + name, + filterMap, + defaultValue: 'no', + }); + const wrapped = computed({ + get: () => filter.value.value === 'yes', + set: value => { + const targetKey = value ? 'yes' : 'no'; + filter.value = options.value.find(o => o.value === targetKey) || {}; + }, + }); + return { filter: wrapped, fetchQueryParams }; + } + export default { name: 'UserTable', components: { @@ -218,6 +346,32 @@ }, }); + const { + filter: joinedWithinFilter, + options: joinedWithinOptions, + fetchQueryParams: joinedWithinFetchQueryParams, + } = useDateWindowFilter({ name: 'joinedWithin', paramName: 'joined_since' }); + + const { + filter: activeWithinFilter, + options: activeWithinOptions, + fetchQueryParams: activeWithinFetchQueryParams, + } = useDateWindowFilter({ name: 'activeWithin', paramName: 'active_since' }); + + const { filter: hasPublishedFilter, fetchQueryParams: hasPublishedFetchQueryParams } = + useBooleanFilter({ + name: 'hasPublished', + label: 'Has published a channel', + paramName: 'published_channel', + }); + + const { filter: hasEditsFilter, fetchQueryParams: hasEditsFetchQueryParams } = + useBooleanFilter({ + name: 'hasEdits', + label: 'Has Studio edits', + paramName: 'has_edits', + }); + onMounted(() => { // The locationFilterMap is built from the options in the CountryField component, // so we need to wait until it's mounted to access them. @@ -240,6 +394,10 @@ ...userTypeFetchQueryParams.value, ...locationFetchQueryParams.value, ...keywordSearchFetchQueryParams.value, + ...joinedWithinFetchQueryParams.value, + ...activeWithinFetchQueryParams.value, + ...hasPublishedFetchQueryParams.value, + ...hasEditsFetchQueryParams.value, }; }); @@ -260,6 +418,12 @@ keywordInput, setKeywords, clearSearch, + joinedWithinFilter, + joinedWithinOptions, + activeWithinFilter, + activeWithinOptions, + hasPublishedFilter, + hasEditsFilter, pagination, loading, loadItems, @@ -333,6 +497,29 @@ mounted() { this.updateTabTitle('Users - Administration'); }, + methods: { + async onDownloadCSV() { + this.$store.dispatch('showSnackbarSimple', 'Generating CSV...'); + try { + const response = await client.get(window.Urls.admin_users_download_csv(), { + params: this.filterFetchQueryParams, + responseType: 'blob', + }); + const filename = `studio_users_${new Date().toISOString().slice(0, 10)}.csv`; + saveAs(response.data, filename); + } catch (error) { + const status = error.response && error.response.status; + if (status === 412) { + this.$store.dispatch( + 'showSnackbarSimple', + 'No filters applied. Pick at least one filter and try again.', + ); + } else { + this.$store.dispatch('showSnackbarSimple', 'CSV download failed. Try again.'); + } + } + }, + }, }; diff --git a/contentcuration/contentcuration/frontend/administration/pages/Users/__tests__/userTable.spec.js b/contentcuration/contentcuration/frontend/administration/pages/Users/__tests__/userTable.spec.js index 7150defbec..c116e78fe2 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Users/__tests__/userTable.spec.js +++ b/contentcuration/contentcuration/frontend/administration/pages/Users/__tests__/userTable.spec.js @@ -4,6 +4,12 @@ import router from '../../../router'; import { RouteNames } from '../../../constants'; import UserTable from '../UserTable'; +jest.mock('shared/client', () => ({ + __esModule: true, + default: { get: jest.fn() }, +})); +jest.mock('file-saver', () => ({ saveAs: jest.fn() })); + const localVue = createLocalVue(); localVue.use(Vuex); @@ -72,6 +78,30 @@ describe('userTable', () => { expect(router.currentRoute.query.keywords).toBe('keyword test'); }); + + it('changing joined-within filter sets joined_since query param to an ISO date', () => { + wrapper.vm.joinedWithinFilter = '3mo'; + const params = wrapper.vm.filterFetchQueryParams; + expect(params.joined_since).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('changing active-within filter sets active_since query param to an ISO date', () => { + wrapper.vm.activeWithinFilter = '1mo'; + const params = wrapper.vm.filterFetchQueryParams; + expect(params.active_since).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('toggling has-published filter sets published_channel=true', () => { + wrapper.vm.hasPublishedFilter = true; + const params = wrapper.vm.filterFetchQueryParams; + expect(params.published_channel).toBe(true); + }); + + it('toggling has-edits filter sets has_edits=true', () => { + wrapper.vm.hasEditsFilter = true; + const params = wrapper.vm.filterFetchQueryParams; + expect(params.has_edits).toBe(true); + }); }); describe('selection', () => { @@ -125,4 +155,56 @@ describe('userTable', () => { expect(wrapper.vm.showEmailDialog).toBe(true); }); }); + + describe('csv download', () => { + beforeEach(() => { + const client = require('shared/client').default; + const { saveAs } = require('file-saver'); + client.get.mockReset(); + client.get.mockResolvedValue({ + data: new Blob(['col1,col2\n1,2'], { type: 'text/csv' }), + }); + saveAs.mockClear(); + }); + + it('renders the Download CSV button when count > 0', () => { + expect(wrapper.find('[data-test="csv"]').exists()).toBe(true); + }); + + it('clicking Download CSV calls the API with the current filter params', async () => { + await wrapper.findComponent('[data-test="csv"]').trigger('click'); + // Flush the microtask queue so the chained .then() runs. + await new Promise(resolve => setImmediate(resolve)); + + const client = require('shared/client').default; + const { saveAs } = require('file-saver'); + expect(client.get).toHaveBeenCalled(); + const [, options] = client.get.mock.calls[0]; + expect(options.responseType).toBe('blob'); + expect(saveAs).toHaveBeenCalled(); + const [savedBlob, savedName] = saveAs.mock.calls[0]; + expect(savedBlob).toBeInstanceOf(Blob); + expect(savedName).toMatch(/^studio_users_\d{4}-\d{2}-\d{2}\.csv$/); + }); + }); + + describe('csv download disabled state', () => { + it('disables Download CSV when count is zero', () => { + const emptyStore = new Store({ + modules: { + userAdmin: { + namespaced: true, + actions: { loadUsers }, + getters: { + users: () => [], + count: () => 0, + }, + }, + }, + }); + const emptyWrapper = makeWrapper(emptyStore); + const button = emptyWrapper.find('[data-test="csv"]'); + expect(button.attributes('disabled') !== undefined || button.props().disabled).toBe(true); + }); + }); }); diff --git a/contentcuration/contentcuration/tests/viewsets/test_user.py b/contentcuration/contentcuration/tests/viewsets/test_user.py index 914404bf24..1f801bd182 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_user.py +++ b/contentcuration/contentcuration/tests/viewsets/test_user.py @@ -1,11 +1,17 @@ +from datetime import timedelta + from django.urls import reverse +from django.utils import timezone +from contentcuration.models import Change from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import generate_create_event from contentcuration.tests.viewsets.base import generate_delete_event from contentcuration.tests.viewsets.base import SyncTestMixin +from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import EDITOR_M2M +from contentcuration.viewsets.sync.constants import UPDATED from contentcuration.viewsets.sync.constants import VIEWER_M2M @@ -146,6 +152,193 @@ def test_admin_delete_user(self): self.user.refresh_from_db() self.assertTrue(self.user.deleted) + # --- Helpers for Kolibri-usage filter tests --- + + def _list_as_admin(self, **params): + self.user.is_admin = True + self.user.save() + self.client.force_authenticate(user=self.user) + return self.client.get(reverse("admin-users-list"), params, format="json") + + def _ids(self, response): + # AdminUserViewSet's paginator returns a plain list when no page_size + # is supplied; with page_size it returns the standard paginated shape. + results = ( + response.data["results"] + if isinstance(response.data, dict) + else response.data + ) + return {str(r["id"]) for r in results} + + def test_admin_users_published_channel_filter_returns_only_publishers(self): + publisher = testdata.user(email="publisher@e.com") + non_publisher = testdata.user(email="nonpub@e.com") + + published_channel = testdata.channel() + published_channel.editors.add(publisher) + published_channel.last_published = timezone.now() + published_channel.save() + + draft_channel = testdata.channel() + draft_channel.editors.add(non_publisher) + + response = self._list_as_admin(published_channel="true") + self.assertEqual(response.status_code, 200, response.content) + + ids = self._ids(response) + self.assertIn(str(publisher.id), ids) + self.assertNotIn(str(non_publisher.id), ids) + + def test_admin_users_has_edits_filter_returns_only_users_with_change_rows(self): + active_editor = testdata.user(email="editor@e.com") + passive_user = testdata.user(email="passive@e.com") + + Change.objects.create( + created_by=active_editor, + channel=self.channel, + table=CHANNEL, + change_type=UPDATED, + kwargs={}, + applied=False, + ) + + response = self._list_as_admin(has_edits="true") + self.assertEqual(response.status_code, 200, response.content) + + ids = self._ids(response) + self.assertIn(str(active_editor.id), ids) + self.assertNotIn(str(passive_user.id), ids) + + def test_admin_users_joined_since_filter_excludes_older_users(self): + recent_user = testdata.user(email="recent@e.com") + recent_user.date_joined = timezone.now() - timedelta(days=10) + recent_user.save() + + old_user = testdata.user(email="old@e.com") + old_user.date_joined = timezone.now() - timedelta(days=365) + old_user.save() + + cutoff = (timezone.now() - timedelta(days=30)).date().isoformat() + response = self._list_as_admin(joined_since=cutoff) + self.assertEqual(response.status_code, 200, response.content) + + ids = self._ids(response) + self.assertIn(str(recent_user.id), ids) + self.assertNotIn(str(old_user.id), ids) + + def test_admin_users_active_since_filter_excludes_dormant_users(self): + recently_active = testdata.user(email="active@e.com") + recently_active.last_login = timezone.now() - timedelta(days=5) + recently_active.save() + + dormant = testdata.user(email="dormant@e.com") + dormant.last_login = timezone.now() - timedelta(days=365) + dormant.save() + + cutoff = (timezone.now() - timedelta(days=30)).date().isoformat() + response = self._list_as_admin(active_since=cutoff) + self.assertEqual(response.status_code, 200, response.content) + + ids = self._ids(response) + self.assertIn(str(recently_active.id), ids) + self.assertNotIn(str(dormant.id), ids) + + # --- Helpers for CSV download tests --- + + def _csv_url(self): + return reverse("admin-users-download-csv") + + def _csv_body(self, response): + return b"".join(response.streaming_content).decode("utf-8") + + def test_admin_users_download_csv_denied_for_non_admin(self): + # self.user is not an admin (test_admin_update_user explicitly flips + # the flag, so the baseline is non-admin). + self.client.force_authenticate(user=self.user) + response = self.client.get(self._csv_url()) + self.assertEqual(response.status_code, 403, response.content) + + def test_admin_users_download_csv_requires_at_least_one_filter(self): + # MissingRequiredParamsException returns 412 (matches existing + # RequiredFilterSet convention used by ChannelUserViewSet). + self.user.is_admin = True + self.user.save() + self.client.force_authenticate(user=self.user) + response = self.client.get(self._csv_url()) + self.assertEqual(response.status_code, 412, response.content) + + def test_admin_users_download_csv_streams_filtered_users(self): + target = testdata.user(email="csv-target@e.com") + target.first_name = "Csv" + target.last_name = "Target" + target.information = { + "locations": ["US", "MX"], + "space_needed": "10GB", + "heard_from": "newsletter", + } + target.save() + + self.user.is_admin = True + self.user.save() + self.client.force_authenticate(user=self.user) + + response = self.client.get(self._csv_url() + f"?ids={target.id}") + # response.content is unavailable on StreamingHttpResponse, so don't + # pass it as the assertEqual message. + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/csv") + self.assertIn("attachment", response["Content-Disposition"]) + self.assertIn("studio_users_", response["Content-Disposition"]) + + body = self._csv_body(response) + # Header row + self.assertIn("First name", body) + self.assertIn("Has published a channel", body) + self.assertIn("Locations (country names)", body) + self.assertIn("Heard from", body) + # Data row + self.assertIn("Csv", body) + self.assertIn("Target", body) + self.assertIn("newsletter", body) + self.assertIn("10GB", body) + # Country code -> name translation + self.assertIn("United States", body) + self.assertIn("Mexico", body) + + def test_admin_users_download_csv_handles_null_information(self): + user_no_info = testdata.user(email="no-info@e.com") + user_no_info.information = None + user_no_info.save() + + self.user.is_admin = True + self.user.save() + self.client.force_authenticate(user=self.user) + response = self.client.get(self._csv_url() + f"?ids={user_no_info.id}") + self.assertEqual(response.status_code, 200) + body = self._csv_body(response) + + lines = body.strip().split("\n") + self.assertEqual(len(lines), 2) + self.assertNotIn("None", lines[1]) + + def test_admin_users_download_csv_respects_filter_params(self): + active = testdata.user(email="active-csv@e.com") + active.is_active = True + active.save() + + inactive = testdata.user(email="inactive-csv@e.com") + inactive.is_active = False + inactive.save() + + self.user.is_admin = True + self.user.save() + self.client.force_authenticate(user=self.user) + response = self.client.get(self._csv_url() + "?is_active=true") + body = self._csv_body(response) + + self.assertIn("active-csv@e.com", body) + self.assertNotIn("inactive-csv@e.com", body) + class ChannelUserCRUDTestCase(StudioAPITestCase): def setUp(self): diff --git a/contentcuration/contentcuration/viewsets/user.py b/contentcuration/contentcuration/viewsets/user.py index 806e5a69c8..6e271e3bc8 100644 --- a/contentcuration/contentcuration/viewsets/user.py +++ b/contentcuration/contentcuration/viewsets/user.py @@ -1,3 +1,5 @@ +import csv +from datetime import date from functools import reduce from django.db import IntegrityError @@ -6,16 +8,19 @@ from django.db.models import Exists from django.db.models import F from django.db.models import IntegerField +from django.db.models import Max from django.db.models import OuterRef from django.db.models import Q from django.db.models import Value from django.db.models.functions import Cast from django.db.models.functions import Concat from django.http import HttpResponseBadRequest +from django.http import StreamingHttpResponse from django.http.response import HttpResponseForbidden from django.http.response import HttpResponseNotFound from django_filters.rest_framework import BooleanFilter from django_filters.rest_framework import CharFilter +from django_filters.rest_framework import DateFilter from django_filters.rest_framework import FilterSet from rest_framework import serializers from rest_framework.decorators import action @@ -27,7 +32,9 @@ from contentcuration.constants import feature_flags from contentcuration.models import boolean_val +from contentcuration.models import Change from contentcuration.models import Channel +from contentcuration.models import Country from contentcuration.models import User from contentcuration.utils.pagination import ValuesViewsetPageNumberPagination from contentcuration.viewsets.base import BulkListSerializer @@ -45,6 +52,24 @@ from contentcuration.viewsets.sync.constants import VIEWER_M2M +# Shared at module scope so filter methods and the CSV action's annotate() +# call agree on semantics. +USER_HAS_PUBLISHED_CHANNEL = Exists( + Channel.objects.filter( + editors=OuterRef("id"), + last_published__isnull=False, + deleted=False, + ) +) +USER_HAS_EDITABLE_CHANNELS = Exists( + Channel.objects.filter(editors=OuterRef("id"), deleted=False) +) +USER_HAS_VIEWABLE_CHANNELS = Exists( + Channel.objects.filter(viewers=OuterRef("id"), deleted=False) +) +USER_HAS_STUDIO_EDITS = Exists(Change.objects.filter(created_by=OuterRef("id"))) + + class IsAdminUser(BasePermission): """ Our custom permission to check admin authorization. @@ -336,6 +361,10 @@ class AdminUserFilter(FilterSet): chef = BooleanFilter(method="filter_chef") location = CharFilter(method="filter_location") ids = CharFilter(method="filter_ids") + published_channel = BooleanFilter(method="filter_published_channel") + has_edits = BooleanFilter(method="filter_has_edits") + active_since = DateFilter(field_name="last_login", lookup_expr="gte") + joined_since = DateFilter(field_name="date_joined", lookup_expr="gte") def filter_ids(self, queryset, name, value): try: @@ -374,9 +403,131 @@ def filter_chef(self, queryset, name, value): def filter_location(self, queryset, name, value): return queryset.filter(information__locations__contains=value) + def filter_published_channel(self, queryset, name, value): + if not value: + return queryset + return queryset.filter(USER_HAS_PUBLISHED_CHANNEL) + + def filter_has_edits(self, queryset, name, value): + if not value: + return queryset + return queryset.filter(USER_HAS_STUDIO_EDITS) + class Meta: model = User - fields = ("keywords", "is_active", "is_admin", "chef", "location") + fields = ( + "keywords", + "is_active", + "is_admin", + "chef", + "location", + "published_channel", + "has_edits", + "active_since", + "joined_since", + ) + + +class AdminUserCSVFilter(AdminUserFilter, RequiredFilterSet): + """Reject CSV requests with no filter params. + + Replaces a row-count cap: an unfiltered CSV export could pull the entire + user table. `RequiredFiltersFilterBackend` sets `required=True` on this + filterset because the action is `detail=False`, which makes + `RequiredFilterSet.qs` raise `MissingRequiredParamsException` (412) when + no filter is supplied. + """ + + +CSV_HEADERS = [ + "First name", + "Last name", + "Email", + "Is active", + "Is admin", + "Date joined", + "Last active", + "Disk space (bytes)", + "Disk space used (bytes)", + "Has editable channels", + "Has viewable channels", + "Has published a channel", + "Most recent publish date", + "Has Studio edits", + "Locations (country names)", + "Primary location", + "Location count", + "Storage needed", + "Heard from", +] + +CSV_VALUES_COLUMNS = ( + "first_name", + "last_name", + "email", + "is_active", + "is_admin", + "date_joined", + "last_login", + "disk_space", + "disk_space_used", + "has_editable_channels", + "has_viewable_channels", + "has_published_channel", + "most_recent_publish", + "has_studio_edits", + "information", +) + + +def _yes_no(value): + return "Yes" if value else "No" + + +def _iso_date(value): + if value is None: + return "" + return value.date().isoformat() if hasattr(value, "date") else value.isoformat() + + +def _build_csv_row(values, country_names): + """Translate one user .values() dict to a CSV row. + + `country_names` is a dict mapping alpha-2 codes to display names, built once + per request. + """ + info = values.get("information") or {} + location_codes = info.get("locations") or [] + location_names = [country_names.get(c, c) for c in location_codes] + + return [ + values["first_name"], + values["last_name"], + values["email"], + _yes_no(values["is_active"]), + _yes_no(values["is_admin"]), + _iso_date(values["date_joined"]), + _iso_date(values["last_login"]), + values["disk_space"], + values["disk_space_used"], + _yes_no(values["has_editable_channels"]), + _yes_no(values["has_viewable_channels"]), + _yes_no(values["has_published_channel"]), + _iso_date(values["most_recent_publish"]), + _yes_no(values["has_studio_edits"]), + ", ".join(location_names), + location_names[0] if location_names else "", + len(location_codes), + info.get("space_needed") or "", + info.get("heard_from") or "", + ] + + +class _Echo: + """File-like stub that returns whatever is written to it (streaming CSV pattern).""" + + def write(self, value): + return value class AdminUserSerializer(UserSerializer): @@ -473,3 +624,38 @@ def metadata(self, request, pk=None): } ) return Response(information) + + @action( + detail=False, + methods=["get"], + url_path="download_csv", + url_name="download-csv", + filterset_class=AdminUserCSVFilter, + ) + def download_csv(self, request): + queryset = self.filter_queryset(self.get_queryset()) + queryset = queryset.annotate( + has_editable_channels=USER_HAS_EDITABLE_CHANNELS, + has_viewable_channels=USER_HAS_VIEWABLE_CHANNELS, + has_published_channel=USER_HAS_PUBLISHED_CHANNEL, + most_recent_publish=Max( + "editable_channels__last_published", + filter=Q(editable_channels__deleted=False), + ), + has_studio_edits=USER_HAS_STUDIO_EDITS, + ) + + country_names = {c.code: c.name for c in Country.objects.all()} + writer = csv.writer(_Echo()) + + def stream(): + yield writer.writerow(CSV_HEADERS) + rows = queryset.values(*CSV_VALUES_COLUMNS).iterator(chunk_size=2000) + for values in rows: + yield writer.writerow(_build_csv_row(values, country_names)) + + response = StreamingHttpResponse(stream(), content_type="text/csv") + response[ + "Content-Disposition" + ] = f'attachment; filename="studio_users_{date.today().isoformat()}.csv"' + return response