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