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