Skip to content

Commit 7969d66

Browse files
rtibblesclaude
andcommitted
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) <noreply@anthropic.com>
1 parent e3bbe30 commit 7969d66

4 files changed

Lines changed: 649 additions & 1 deletion

File tree

contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
:text="`Email ${$formatNumber(count)} ${count === 1 ? 'user' : 'users'}`"
1212
@click="showMassEmailDialog = true"
1313
/>
14+
<IconButton
15+
icon="download"
16+
class="ma-0"
17+
:color="$themeTokens.primary"
18+
text="Download CSV"
19+
data-test="csv"
20+
:disabled="!count"
21+
@click="onDownloadCSV"
22+
/>
1423
</h1>
1524
<EmailUsersDialog
1625
v-model="showMassEmailDialog"
@@ -72,6 +81,65 @@
7281
/>
7382
</VFlex>
7483
</VLayout>
84+
<VLayout
85+
wrap
86+
class="mb-2"
87+
>
88+
<VFlex
89+
xs12
90+
sm6
91+
md3
92+
class="px-3"
93+
>
94+
<VSelect
95+
v-model="joinedWithinFilter"
96+
:items="joinedWithinOptions"
97+
item-text="label"
98+
item-value="value"
99+
label="Joined within"
100+
box
101+
:menu-props="{ offsetY: true }"
102+
/>
103+
</VFlex>
104+
<VFlex
105+
xs12
106+
sm6
107+
md3
108+
class="px-3"
109+
>
110+
<VSelect
111+
v-model="activeWithinFilter"
112+
:items="activeWithinOptions"
113+
item-text="label"
114+
item-value="value"
115+
label="Active within"
116+
box
117+
:menu-props="{ offsetY: true }"
118+
/>
119+
</VFlex>
120+
<VFlex
121+
xs12
122+
sm6
123+
md3
124+
class="align-center d-flex px-3"
125+
>
126+
<Checkbox
127+
v-model="hasPublishedFilter"
128+
label="Has published a channel"
129+
/>
130+
</VFlex>
131+
<VFlex
132+
xs12
133+
sm6
134+
md3
135+
class="align-center d-flex px-3"
136+
>
137+
<Checkbox
138+
v-model="hasEditsFilter"
139+
label="Has Studio edits"
140+
/>
141+
</VFlex>
142+
</VLayout>
75143
<VDataTable
76144
v-model="selected"
77145
:headers="headers"
@@ -141,10 +209,12 @@
141209
import { ref, onMounted, computed, getCurrentInstance } from 'vue';
142210
import { mapGetters } from 'vuex';
143211
import transform from 'lodash/transform';
212+
import { saveAs } from 'file-saver';
144213
import { useTable } from '../../composables/useTable';
145214
import { RouteNames, rowsPerPageItems } from '../../constants';
146215
import EmailUsersDialog from './EmailUsersDialog';
147216
import UserItem from './UserItem';
217+
import client from 'shared/client';
148218
import { useFilter } from 'shared/composables/useFilter';
149219
import { useKeywordSearch } from 'shared/composables/useKeywordSearch';
150220
import { routerMixin } from 'shared/mixins';
@@ -160,6 +230,64 @@
160230
sushichef: { label: 'Sushi chef', params: { chef: true } },
161231
};
162232
233+
const DATE_WINDOWS = [
234+
{ key: 'any', label: 'Any time', months: null },
235+
{ key: '1mo', label: 'Last month', months: 1 },
236+
{ key: '3mo', label: 'Last 3 months', months: 3 },
237+
{ key: '6mo', label: 'Last 6 months', months: 6 },
238+
{ key: '1yr', label: 'Last year', months: 12 },
239+
];
240+
241+
function buildDateWindowFilterMap(paramName) {
242+
const map = {};
243+
for (const window of DATE_WINDOWS) {
244+
if (window.months === null) {
245+
map[window.key] = { label: window.label, params: {} };
246+
} else {
247+
const cutoff = new Date();
248+
cutoff.setMonth(cutoff.getMonth() - window.months);
249+
const iso = cutoff.toISOString().slice(0, 10);
250+
map[window.key] = { label: window.label, params: { [paramName]: iso } };
251+
}
252+
}
253+
return map;
254+
}
255+
256+
function useDateWindowFilter({ name, paramName }) {
257+
const { filter, options, fetchQueryParams } = useFilter({
258+
name,
259+
filterMap: buildDateWindowFilterMap(paramName),
260+
defaultValue: 'any',
261+
});
262+
const wrapped = computed({
263+
get: () => filter.value.value || 'any',
264+
set: value => {
265+
filter.value = options.value.find(o => o.value === value) || {};
266+
},
267+
});
268+
return { filter: wrapped, options, fetchQueryParams };
269+
}
270+
271+
function useBooleanFilter({ name, label, paramName }) {
272+
const filterMap = {
273+
no: { label: 'Any', params: {} },
274+
yes: { label, params: { [paramName]: true } },
275+
};
276+
const { filter, options, fetchQueryParams } = useFilter({
277+
name,
278+
filterMap,
279+
defaultValue: 'no',
280+
});
281+
const wrapped = computed({
282+
get: () => filter.value.value === 'yes',
283+
set: value => {
284+
const targetKey = value ? 'yes' : 'no';
285+
filter.value = options.value.find(o => o.value === targetKey) || {};
286+
},
287+
});
288+
return { filter: wrapped, fetchQueryParams };
289+
}
290+
163291
export default {
164292
name: 'UserTable',
165293
components: {
@@ -218,6 +346,32 @@
218346
},
219347
});
220348
349+
const {
350+
filter: joinedWithinFilter,
351+
options: joinedWithinOptions,
352+
fetchQueryParams: joinedWithinFetchQueryParams,
353+
} = useDateWindowFilter({ name: 'joinedWithin', paramName: 'joined_since' });
354+
355+
const {
356+
filter: activeWithinFilter,
357+
options: activeWithinOptions,
358+
fetchQueryParams: activeWithinFetchQueryParams,
359+
} = useDateWindowFilter({ name: 'activeWithin', paramName: 'active_since' });
360+
361+
const { filter: hasPublishedFilter, fetchQueryParams: hasPublishedFetchQueryParams } =
362+
useBooleanFilter({
363+
name: 'hasPublished',
364+
label: 'Has published a channel',
365+
paramName: 'published_channel',
366+
});
367+
368+
const { filter: hasEditsFilter, fetchQueryParams: hasEditsFetchQueryParams } =
369+
useBooleanFilter({
370+
name: 'hasEdits',
371+
label: 'Has Studio edits',
372+
paramName: 'has_edits',
373+
});
374+
221375
onMounted(() => {
222376
// The locationFilterMap is built from the options in the CountryField component,
223377
// so we need to wait until it's mounted to access them.
@@ -240,6 +394,10 @@
240394
...userTypeFetchQueryParams.value,
241395
...locationFetchQueryParams.value,
242396
...keywordSearchFetchQueryParams.value,
397+
...joinedWithinFetchQueryParams.value,
398+
...activeWithinFetchQueryParams.value,
399+
...hasPublishedFetchQueryParams.value,
400+
...hasEditsFetchQueryParams.value,
243401
};
244402
});
245403
@@ -260,6 +418,12 @@
260418
keywordInput,
261419
setKeywords,
262420
clearSearch,
421+
joinedWithinFilter,
422+
joinedWithinOptions,
423+
activeWithinFilter,
424+
activeWithinOptions,
425+
hasPublishedFilter,
426+
hasEditsFilter,
263427
pagination,
264428
loading,
265429
loadItems,
@@ -333,6 +497,29 @@
333497
mounted() {
334498
this.updateTabTitle('Users - Administration');
335499
},
500+
methods: {
501+
async onDownloadCSV() {
502+
this.$store.dispatch('showSnackbarSimple', 'Generating CSV...');
503+
try {
504+
const response = await client.get(window.Urls.admin_users_download_csv(), {
505+
params: this.filterFetchQueryParams,
506+
responseType: 'blob',
507+
});
508+
const filename = `studio_users_${new Date().toISOString().slice(0, 10)}.csv`;
509+
saveAs(response.data, filename);
510+
} catch (error) {
511+
const status = error.response && error.response.status;
512+
if (status === 412) {
513+
this.$store.dispatch(
514+
'showSnackbarSimple',
515+
'No filters applied. Pick at least one filter and try again.',
516+
);
517+
} else {
518+
this.$store.dispatch('showSnackbarSimple', 'CSV download failed. Try again.');
519+
}
520+
}
521+
},
522+
},
336523
};
337524
338525
</script>

contentcuration/contentcuration/frontend/administration/pages/Users/__tests__/userTable.spec.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import router from '../../../router';
44
import { RouteNames } from '../../../constants';
55
import UserTable from '../UserTable';
66

7+
jest.mock('shared/client', () => ({
8+
__esModule: true,
9+
default: { get: jest.fn() },
10+
}));
11+
jest.mock('file-saver', () => ({ saveAs: jest.fn() }));
12+
713
const localVue = createLocalVue();
814

915
localVue.use(Vuex);
@@ -72,6 +78,30 @@ describe('userTable', () => {
7278

7379
expect(router.currentRoute.query.keywords).toBe('keyword test');
7480
});
81+
82+
it('changing joined-within filter sets joined_since query param to an ISO date', () => {
83+
wrapper.vm.joinedWithinFilter = '3mo';
84+
const params = wrapper.vm.filterFetchQueryParams;
85+
expect(params.joined_since).toMatch(/^\d{4}-\d{2}-\d{2}$/);
86+
});
87+
88+
it('changing active-within filter sets active_since query param to an ISO date', () => {
89+
wrapper.vm.activeWithinFilter = '1mo';
90+
const params = wrapper.vm.filterFetchQueryParams;
91+
expect(params.active_since).toMatch(/^\d{4}-\d{2}-\d{2}$/);
92+
});
93+
94+
it('toggling has-published filter sets published_channel=true', () => {
95+
wrapper.vm.hasPublishedFilter = true;
96+
const params = wrapper.vm.filterFetchQueryParams;
97+
expect(params.published_channel).toBe(true);
98+
});
99+
100+
it('toggling has-edits filter sets has_edits=true', () => {
101+
wrapper.vm.hasEditsFilter = true;
102+
const params = wrapper.vm.filterFetchQueryParams;
103+
expect(params.has_edits).toBe(true);
104+
});
75105
});
76106

77107
describe('selection', () => {
@@ -125,4 +155,56 @@ describe('userTable', () => {
125155
expect(wrapper.vm.showEmailDialog).toBe(true);
126156
});
127157
});
158+
159+
describe('csv download', () => {
160+
beforeEach(() => {
161+
const client = require('shared/client').default;
162+
const { saveAs } = require('file-saver');
163+
client.get.mockReset();
164+
client.get.mockResolvedValue({
165+
data: new Blob(['col1,col2\n1,2'], { type: 'text/csv' }),
166+
});
167+
saveAs.mockClear();
168+
});
169+
170+
it('renders the Download CSV button when count > 0', () => {
171+
expect(wrapper.find('[data-test="csv"]').exists()).toBe(true);
172+
});
173+
174+
it('clicking Download CSV calls the API with the current filter params', async () => {
175+
await wrapper.findComponent('[data-test="csv"]').trigger('click');
176+
// Flush the microtask queue so the chained .then() runs.
177+
await new Promise(resolve => setImmediate(resolve));
178+
179+
const client = require('shared/client').default;
180+
const { saveAs } = require('file-saver');
181+
expect(client.get).toHaveBeenCalled();
182+
const [, options] = client.get.mock.calls[0];
183+
expect(options.responseType).toBe('blob');
184+
expect(saveAs).toHaveBeenCalled();
185+
const [savedBlob, savedName] = saveAs.mock.calls[0];
186+
expect(savedBlob).toBeInstanceOf(Blob);
187+
expect(savedName).toMatch(/^studio_users_\d{4}-\d{2}-\d{2}\.csv$/);
188+
});
189+
});
190+
191+
describe('csv download disabled state', () => {
192+
it('disables Download CSV when count is zero', () => {
193+
const emptyStore = new Store({
194+
modules: {
195+
userAdmin: {
196+
namespaced: true,
197+
actions: { loadUsers },
198+
getters: {
199+
users: () => [],
200+
count: () => 0,
201+
},
202+
},
203+
},
204+
});
205+
const emptyWrapper = makeWrapper(emptyStore);
206+
const button = emptyWrapper.find('[data-test="csv"]');
207+
expect(button.attributes('disabled') !== undefined || button.props().disabled).toBe(true);
208+
});
209+
});
128210
});

0 commit comments

Comments
 (0)