diff --git a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue
index c409ddd82b..915d022587 100644
--- a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue
+++ b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue
@@ -2,20 +2,20 @@
- {{ `${$formatNumber(count)} ${count === 1 ? 'user' : 'users'}` }}
+ {{ userCount$({ count }) }}
-
-
+
-
-
-
+
-
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
-
+
-
-
-
+
+
+
+
+
+
+
-
@@ -174,11 +161,11 @@
- ({{ selectedCount }})
+ ({{ selected.length }})
@@ -204,325 +191,335 @@
-
-
+
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 c116e78fe2..0b3cbec6de 100644
--- a/contentcuration/contentcuration/frontend/administration/pages/Users/__tests__/userTable.spec.js
+++ b/contentcuration/contentcuration/frontend/administration/pages/Users/__tests__/userTable.spec.js
@@ -1,8 +1,27 @@
-import { mount, createLocalVue } from '@vue/test-utils';
-import Vuex, { Store } from 'vuex';
+import { render, screen, waitFor, within, configure } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
+import { Store } from 'vuex';
import router from '../../../router';
import { RouteNames } from '../../../constants';
import UserTable from '../UserTable';
+import { usersStrings } from '../usersStrings';
+import { commonStrings } from 'shared/strings/commonStrings';
+
+const {
+ userCount$,
+ clearFiltersAction$,
+ userTypeLabel$,
+ targetLocationLabel$,
+ searchLabel$,
+ joinedWithinLabel$,
+ activeWithinLabel$,
+ hasPublishedLabel$,
+ hasStudioActivityLabel$,
+ userTypeAdministrators$,
+ userTypeAll$,
+} = usersStrings;
+
+const { clearAction$ } = commonStrings;
jest.mock('shared/client', () => ({
__esModule: true,
@@ -10,201 +29,344 @@ jest.mock('shared/client', () => ({
}));
jest.mock('file-saver', () => ({ saveAs: jest.fn() }));
-const localVue = createLocalVue();
+configure({ testIdAttribute: 'data-test' });
-localVue.use(Vuex);
-localVue.use(router);
+const USER_IDS = ['user-a', 'user-b', 'user-c'];
+const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
-const userList = ['test', 'user', 'table'];
+const mockLoadUsers = jest.fn(() => Promise.resolve({}));
+const mockSendEmail = jest.fn(() => Promise.resolve());
-function makeWrapper(store) {
- router.replace({ name: RouteNames.USERS });
-
- const wrapper = mount(UserTable, {
- router,
- store,
- localVue,
- stubs: {
- UserItem: true,
- EmailUsersDialog: true,
+function createStore({ users = USER_IDS } = {}) {
+ return new Store({
+ modules: {
+ userAdmin: {
+ namespaced: true,
+ actions: {
+ loadUsers: mockLoadUsers,
+ sendEmail: mockSendEmail,
+ },
+ getters: {
+ users: () => users,
+ count: () => users.length,
+ getUsers: () => ids => ids.map(id => ({ id, email: `${id}@test.com` })),
+ },
+ },
},
});
+}
- return wrapper;
+function renderComponent({ users, query = {} } = {}) {
+ router.replace({ name: RouteNames.USERS, query }).catch(() => {});
+ return render(UserTable, {
+ store: createStore({ users }),
+ routes: router,
+ stubs: { UserItem: true },
+ });
+}
+
+const renderWithFilters = query => renderComponent({ query });
+
+function lastFetchParams() {
+ const { calls } = mockLoadUsers.mock;
+ return calls[calls.length - 1][1];
}
-describe('userTable', () => {
- let wrapper, store;
- const loadUsers = jest.fn(() => Promise.resolve({}));
+const clearFiltersLink = () => screen.queryByText(clearFiltersAction$());
+const selectAllCheckbox = () => within(screen.getByRole('table')).getAllByRole('checkbox')[0];
+
+describe('UserTable', () => {
+ let user;
beforeEach(() => {
- store = new Store({
- modules: {
- userAdmin: {
- namespaced: true,
- actions: {
- loadUsers,
- },
- getters: {
- users: () => userList,
- count: () => userList.length,
- },
- },
- },
+ user = userEvent.setup();
+ jest.clearAllMocks();
+ require('shared/client').default.get.mockResolvedValue({
+ data: new Blob(['col1,col2\n1,2'], { type: 'text/csv' }),
});
- wrapper = makeWrapper(store);
});
- afterEach(() => {
- loadUsers.mockRestore();
+
+ describe('heading', () => {
+ it('pluralises the match count', () => {
+ renderComponent();
+
+ expect(screen.getByText(userCount$({ count: USER_IDS.length }))).toBeInTheDocument();
+ });
+
+ it('uses the singular form for one match', () => {
+ renderComponent({ users: [USER_IDS[0]] });
+
+ expect(screen.getByText(userCount$({ count: 1 }))).toBeInTheDocument();
+ });
});
- describe('filters', () => {
- it('changing user type filter should set query params', () => {
- wrapper.vm.userTypeFilter = 'administrator';
- expect(router.currentRoute.query.userType).toBe('administrator');
+ describe('filter controls', () => {
+ it('renders every filter control', () => {
+ renderComponent();
+
+ expect(screen.getByText(userTypeLabel$())).toBeInTheDocument();
+ expect(screen.getByText(joinedWithinLabel$())).toBeInTheDocument();
+ expect(screen.getByText(activeWithinLabel$())).toBeInTheDocument();
+
+ expect(screen.getByLabelText(targetLocationLabel$())).toBeInTheDocument();
+ expect(screen.getByLabelText(searchLabel$())).toBeInTheDocument();
+ expect(screen.getByLabelText(hasPublishedLabel$())).toBeInTheDocument();
+ expect(screen.getByLabelText(hasStudioActivityLabel$())).toBeInTheDocument();
});
- it('changing location filter should set query params', () => {
- wrapper.vm.locationFilter = 'Afghanistan';
- expect(router.currentRoute.query.location).toBe('Afghanistan');
+ it('typing a search term fetches users filtered by keyword', async () => {
+ renderComponent();
+
+ await user.type(screen.getByLabelText(searchLabel$()), 'keyword test');
+
+ await waitFor(() => {
+ expect(lastFetchParams()).toMatchObject({ keywords: 'keyword test' });
+ });
});
- it('changing search text should set query params', () => {
- jest.useFakeTimers();
- wrapper.vm.keywordInput = 'keyword test';
- wrapper.vm.setKeywords();
- jest.runAllTimers();
- jest.useRealTimers();
+ it("the search field's clear button drops the keyword filter", async () => {
+ renderComponent();
- expect(router.currentRoute.query.keywords).toBe('keyword test');
+ await user.type(screen.getByLabelText(searchLabel$()), 'keyword test');
+ await waitFor(() => {
+ expect(router.currentRoute.query.keywords).toBe('keyword test');
+ });
+
+ await user.click(screen.getByRole('button', { name: clearAction$() }));
+
+ await waitFor(() => {
+ expect(router.currentRoute.query.keywords).toBeUndefined();
+ });
+ expect(screen.getByLabelText(searchLabel$())).toHaveValue('');
});
- 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('ticking "has published a channel" fetches users filtered by published_channel', async () => {
+ renderComponent();
+
+ await user.click(screen.getByLabelText(hasPublishedLabel$()));
+
+ await waitFor(() => {
+ expect(lastFetchParams()).toMatchObject({ published_channel: true });
+ });
});
- 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('ticking "has Studio activity" fetches users filtered by has_edits', async () => {
+ renderComponent();
+
+ await user.click(screen.getByLabelText(hasStudioActivityLabel$()));
+
+ await waitFor(() => {
+ expect(lastFetchParams()).toMatchObject({ has_edits: true });
+ });
});
- 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('a user type selection fetches users filtered by that type', async () => {
+ renderComponent();
+
+ await user.click(screen.getByText(userTypeLabel$()));
+ await user.click(await screen.findByText(userTypeAdministrators$()));
+
+ await waitFor(() => {
+ expect(lastFetchParams()).toMatchObject({ is_admin: 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);
+ it('a joined-within selection fetches users filtered by an ISO joined_since date', async () => {
+ renderWithFilters({ joinedWithin: '3mo' });
+
+ await waitFor(() => {
+ expect(lastFetchParams().joined_since).toMatch(ISO_DATE);
+ });
});
- });
- describe('selection', () => {
- it('selectAll should set selected to channel list', () => {
- wrapper.vm.selectAll = true;
- expect(wrapper.vm.selected).toEqual(userList);
+ it('an active-within selection fetches users filtered by an ISO active_since date', async () => {
+ renderWithFilters({ activeWithin: '1mo' });
+
+ await waitFor(() => {
+ expect(lastFetchParams().active_since).toMatch(ISO_DATE);
+ });
});
- it('removing selectAll should set selected to empty list', () => {
- wrapper.vm.selected = userList;
- wrapper.vm.selectAll = false;
- wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.selected).toEqual([]);
+ it('a target location selection fetches users filtered by that location', async () => {
+ renderWithFilters({ location: 'Afghanistan' });
+
+ await waitFor(() => {
+ expect(lastFetchParams()).toMatchObject({ location: 'Afghanistan' });
});
});
+ });
+
+ describe('clearing filters', () => {
+ it('is not offered on a page with no filters applied', () => {
+ renderComponent();
- it('selectedCount should match the selected length', () => {
- wrapper.vm.selected = ['test'];
- expect(wrapper.vm.selectedCount).toBe(1);
+ expect(clearFiltersLink()).not.toBeInTheDocument();
});
- it('selected should clear on query changes', () => {
- wrapper.vm.selected = ['test'];
- router.push({
- ...wrapper.vm.$route,
- query: {
- param: 'test',
- },
+ it('is offered once a filter is applied', async () => {
+ renderComponent();
+
+ await user.click(screen.getByLabelText(hasPublishedLabel$()));
+
+ await waitFor(() => {
+ expect(clearFiltersLink()).toBeInTheDocument();
});
- wrapper.vm.$nextTick(() => {
- expect(wrapper.vm.selected).toEqual([]);
+ });
+
+ it('is offered for a user type of "All", which narrows nothing but is still a selection', async () => {
+ renderComponent();
+
+ await user.click(screen.getByText(userTypeLabel$()));
+ await user.click(await screen.findByText(userTypeAll$()));
+
+ await waitFor(() => {
+ expect(clearFiltersLink()).toBeInTheDocument();
});
+ expect(lastFetchParams()).not.toHaveProperty('is_admin');
});
- });
- describe('bulk actions', () => {
- it('should be hidden if no items are selected', () => {
- expect(wrapper.find('[data-test="email"]').exists()).toBe(false);
+ it('stays unoffered for date windows left at their default', () => {
+ renderWithFilters({ joinedWithin: 'any', activeWithin: 'any' });
+
+ expect(clearFiltersLink()).not.toBeInTheDocument();
});
- it('should be visible if items are selected', async () => {
- wrapper.vm.selected = userList;
- await wrapper.vm.$nextTick();
- expect(wrapper.find('[data-test="email"]').exists()).toBe(true);
+ it('is withdrawn again after a checkbox is ticked and unticked', async () => {
+ renderComponent();
+ const checkbox = screen.getByLabelText(hasPublishedLabel$());
+
+ await user.click(checkbox);
+ await waitFor(() => {
+ expect(clearFiltersLink()).toBeInTheDocument();
+ });
+
+ await user.click(checkbox);
+
+ await waitFor(() => {
+ expect(clearFiltersLink()).not.toBeInTheDocument();
+ });
});
- it('email should open email dialog', async () => {
- wrapper.vm.selected = userList;
- await wrapper.vm.$nextTick();
- await wrapper.findComponent('[data-test="email"]').trigger('click');
- expect(wrapper.vm.showEmailDialog).toBe(true);
+ it('clears the checkboxes and the keyword search', async () => {
+ renderComponent();
+
+ await user.type(screen.getByLabelText(searchLabel$()), 'keyword test');
+ // useKeywordSearch debounces, so a pending write would land after the clear.
+ await waitFor(() => {
+ expect(router.currentRoute.query.keywords).toBe('keyword test');
+ });
+ await user.click(screen.getByLabelText(hasPublishedLabel$()));
+ await user.click(screen.getByLabelText(hasStudioActivityLabel$()));
+ await waitFor(() => {
+ expect(clearFiltersLink()).toBeInTheDocument();
+ });
+
+ await user.click(clearFiltersLink());
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(searchLabel$())).toHaveValue('');
+ });
+ expect(screen.getByLabelText(hasPublishedLabel$())).not.toBeChecked();
+ expect(screen.getByLabelText(hasStudioActivityLabel$())).not.toBeChecked();
+ expect(clearFiltersLink()).not.toBeInTheDocument();
+ });
+
+ it('removes every filter query param while preserving pagination and sorting', async () => {
+ renderWithFilters({
+ userType: 'administrator',
+ location: 'Afghanistan',
+ joinedWithin: '3mo',
+ activeWithin: '1mo',
+ hasPublished: 'yes',
+ hasEdits: 'yes',
+ keywords: 'keyword test',
+ page: '3',
+ page_size: '25',
+ sortBy: 'email',
+ descending: 'false',
+ });
+
+ await user.click(clearFiltersLink());
+
+ await waitFor(() => {
+ expect(Object.keys(router.currentRoute.query).sort()).toEqual([
+ 'descending',
+ 'page',
+ 'page_size',
+ 'sortBy',
+ ]);
+ });
+ expect(router.currentRoute.query.sortBy).toBe('email');
});
});
- 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' }),
+ describe('selection and bulk actions', () => {
+ it('offers no bulk email action until users are selected', () => {
+ renderComponent();
+
+ expect(screen.queryByTestId('email')).not.toBeInTheDocument();
+ });
+
+ it('selecting all users offers a bulk email action for them', async () => {
+ renderComponent();
+
+ await user.click(selectAllCheckbox());
+
+ expect(await screen.findByTestId('email')).toBeInTheDocument();
+ expect(screen.getByText(`(${USER_IDS.length})`)).toBeInTheDocument();
+ });
+
+ it('discards the selection when the filters change', async () => {
+ renderComponent();
+
+ await user.click(selectAllCheckbox());
+ expect(await screen.findByTestId('email')).toBeInTheDocument();
+
+ await user.click(screen.getByLabelText(hasPublishedLabel$()));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('email')).not.toBeInTheDocument();
});
- saveAs.mockClear();
});
- it('renders the Download CSV button when count > 0', () => {
- expect(wrapper.find('[data-test="csv"]').exists()).toBe(true);
+ it('the bulk email action opens the send email dialog', async () => {
+ renderComponent();
+
+ await user.click(selectAllCheckbox());
+ await user.click(await screen.findByTestId('email'));
+
+ // EmailUsersDialog has no $trs, so there is no key to reference for its title.
+ expect(await screen.findByRole('heading', { name: 'Send email' })).toBeInTheDocument();
});
+ });
- 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));
+ describe('CSV download', () => {
+ it('offers the download when there are users to export', () => {
+ renderComponent();
+ expect(screen.getByTestId('csv')).toBeEnabled();
+ });
+
+ it('is unavailable when there are no users to export', () => {
+ renderComponent({ users: [] });
+
+ expect(screen.getByTestId('csv')).toBeDisabled();
+ });
+
+ it('downloads a dated CSV built from the current filters', async () => {
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();
+ renderComponent();
+
+ await user.click(screen.getByTestId('csv'));
+
+ await waitFor(() => {
+ expect(saveAs).toHaveBeenCalled();
+ });
+ expect(client.get.mock.calls[0][1].responseType).toBe('blob');
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/frontend/administration/pages/Users/usersStrings.js b/contentcuration/contentcuration/frontend/administration/pages/Users/usersStrings.js
new file mode 100644
index 0000000000..b941588194
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/administration/pages/Users/usersStrings.js
@@ -0,0 +1,159 @@
+import { createTranslator } from 'shared/i18n';
+
+export const usersStrings = createTranslator('UsersStrings', {
+ userCount: {
+ message: '{count, plural,\n =1 {# user}\n other {# users}}',
+ context: 'Heading above the administration users table, showing how many users match',
+ },
+ emailUsersAction: {
+ message: 'Email {count, plural,\n =1 {# user}\n other {# users}}',
+ context: 'Action to email every user matching the current filters',
+ },
+ emailAction: {
+ message: 'Email',
+ context: 'Action to email the users selected in the table',
+ },
+ downloadCSVAction: {
+ message: 'Download CSV',
+ context: 'Action to export the filtered users as a CSV file',
+ },
+ clearFiltersAction: {
+ message: 'Clear filters',
+ context: 'Action to remove every filter applied to the users table',
+ },
+
+ userTypeLabel: {
+ message: 'User Type',
+ context: 'Label of the dropdown filtering users by their type, such as administrators',
+ },
+ targetLocationLabel: {
+ message: 'Target location',
+ context: 'Label of the dropdown filtering users by the country they work in',
+ },
+ searchLabel: {
+ message: 'Search for a user...',
+ context: 'Placeholder of the users search field',
+ },
+ joinedWithinLabel: {
+ message: 'Joined within',
+ context: 'Label of the dropdown filtering users by how recently they registered',
+ },
+ activeWithinLabel: {
+ message: 'Active within',
+ context: 'Label of the dropdown filtering users by how recently they signed in',
+ },
+ hasPublishedLabel: {
+ message: 'Has published a channel',
+ context: 'Checkbox filtering to users who have published at least one channel',
+ },
+ hasStudioActivityLabel: {
+ message: 'Has Studio activity',
+ context: 'Checkbox filtering to users who have ever made a change in Studio',
+ },
+
+ userTypeAll: {
+ message: 'All',
+ context: 'User type option that applies no filtering',
+ },
+ userTypeActive: {
+ message: 'Active',
+ context: 'User type option for accounts that are currently active',
+ },
+ userTypeInactive: {
+ message: 'Inactive',
+ context: 'User type option for accounts that have been deactivated',
+ },
+ userTypeAdministrators: {
+ message: 'Administrators',
+ context: 'User type option for accounts with administrator privileges',
+ },
+ userTypeSushiChef: {
+ message: 'Sushi chef',
+ context:
+ 'User type option for accounts that upload content using a Sushi Chef script. Sushi Chef is a proper name and is not translated.',
+ },
+ booleanFilterAny: {
+ message: 'Any',
+ context: 'Option of a checkbox filter meaning that the filter is not applied',
+ },
+
+ dateWindowAnyTime: {
+ message: 'Any time',
+ context: 'Date range option that applies no filtering',
+ },
+ dateWindowLastMonth: {
+ message: 'Last month',
+ context: 'Date range option covering the past month',
+ },
+ dateWindowLast3Months: {
+ message: 'Last 3 months',
+ context: 'Date range option covering the past three months',
+ },
+ dateWindowLast6Months: {
+ message: 'Last 6 months',
+ context: 'Date range option covering the past six months',
+ },
+ dateWindowLastYear: {
+ message: 'Last year',
+ context: 'Date range option covering the past year',
+ },
+
+ nameHeader: {
+ message: 'Name',
+ context: "Column heading for the user's name",
+ },
+ emailHeader: {
+ message: 'Email',
+ context: "Column heading for the user's email address",
+ },
+ diskSpaceHeader: {
+ message: 'Disk space',
+ context: 'Column heading for how much storage the user has been granted',
+ },
+ canEditHeader: {
+ message: 'Can edit',
+ context: 'Column heading for how many channels the user can edit',
+ },
+ canViewHeader: {
+ message: 'Can view',
+ context: 'Column heading for how many channels the user can view',
+ },
+ dateJoinedHeader: {
+ message: 'Date joined',
+ context: 'Column heading for when the user registered',
+ },
+ lastActiveHeader: {
+ message: 'Last active',
+ context: 'Column heading for when the user last signed in',
+ },
+ actionsHeader: {
+ message: 'Actions',
+ context: 'Column heading for the per-user actions menu',
+ },
+
+ loadingMessage: {
+ message: 'Loading...',
+ context: 'Shown in the table while users are being fetched',
+ },
+ noUsersFoundMessage: {
+ message: 'No users found',
+ context: 'Shown in the table when no users match the current filters',
+ },
+ generatingCSVMessage: {
+ message: 'Generating CSV...',
+ context: 'Notification shown while the CSV export is being prepared',
+ },
+ noFiltersAppliedMessage: {
+ message: 'No filters applied. Pick at least one filter and try again.',
+ context: 'Notification shown when a CSV export is attempted with no filters set',
+ },
+ csvDownloadFailedMessage: {
+ message: 'CSV download failed. Try again.',
+ context: 'Notification shown when the CSV export request fails',
+ },
+
+ tabTitle: {
+ message: 'Users - Administration',
+ context: 'Browser tab title for the administration users page',
+ },
+});
diff --git a/contentcuration/contentcuration/tests/views/test_settings.py b/contentcuration/contentcuration/tests/views/test_settings.py
index ed23fb0d70..2c75541fc1 100644
--- a/contentcuration/contentcuration/tests/views/test_settings.py
+++ b/contentcuration/contentcuration/tests/views/test_settings.py
@@ -15,6 +15,63 @@ def setUp(self):
self.view.request = mock.Mock()
self.view.request.user = testdata.user(email="tester@tester.com")
+ def _form(self, **overrides):
+ data = dict(
+ storage="storage",
+ kind="kind",
+ resource_count="resource_count",
+ resource_size="resource_size",
+ creators="creators",
+ sample_link="sample_link",
+ license="license",
+ public="channel1, channel2",
+ audience="audience",
+ import_count="import_count",
+ location="location",
+ uploading_for="uploading_for",
+ organization_type="organization_type",
+ time_constraint="time_constraint",
+ message="message",
+ )
+ data.update(overrides)
+ form = StorageRequestForm(data=data)
+ self.assertTrue(form.is_valid())
+ return form
+
+ def test_storage_request_records_requested_storage(self):
+ user = self.view.request.user
+ user.information = {"space_needed": "500MB", "heard_from": "newsletter"}
+ user.save()
+
+ with mock.patch("contentcuration.views.settings.send_mail"):
+ self.view.form_valid(self._form(storage="10GB"))
+
+ user.refresh_from_db()
+ self.assertEqual(user.information["latest_storage_request"], "10GB")
+ self.assertEqual(user.information["space_needed"], "500MB")
+ self.assertEqual(user.information["heard_from"], "newsletter")
+
+ def test_storage_request_records_requested_storage_without_prior_information(self):
+ user = self.view.request.user
+ user.information = None
+ user.save()
+
+ with mock.patch("contentcuration.views.settings.send_mail"):
+ self.view.form_valid(self._form(storage="1TB"))
+
+ user.refresh_from_db()
+ self.assertEqual(user.information["latest_storage_request"], "1TB")
+
+ def test_storage_request_overwrites_the_previous_request(self):
+ user = self.view.request.user
+
+ with mock.patch("contentcuration.views.settings.send_mail"):
+ self.view.form_valid(self._form(storage="1GB"))
+ self.view.form_valid(self._form(storage="2GB"))
+
+ user.refresh_from_db()
+ self.assertEqual(user.information["latest_storage_request"], "2GB")
+
def test_storage_request(self):
with mock.patch("contentcuration.views.settings.send_mail") as send_mail:
diff --git a/contentcuration/contentcuration/tests/viewsets/test_user.py b/contentcuration/contentcuration/tests/viewsets/test_user.py
index 4e050888d2..8e37fbe055 100644
--- a/contentcuration/contentcuration/tests/viewsets/test_user.py
+++ b/contentcuration/contentcuration/tests/viewsets/test_user.py
@@ -306,6 +306,26 @@ def test_admin_users_download_csv_streams_filtered_users(self):
self.assertIn("United States", body)
self.assertIn("Mexico", body)
+ def test_admin_users_download_csv_prefers_the_latest_storage_request(self):
+ target = testdata.user(email="csv-storage@e.com")
+ target.information = {
+ "space_needed": "500MB",
+ "latest_storage_request": "10GB",
+ }
+ 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}")
+ self.assertEqual(response.status_code, 200)
+
+ body = self._csv_body(response)
+ self.assertIn("Has Studio activity", body)
+ self.assertIn("10GB", body)
+ self.assertNotIn("500MB", 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
diff --git a/contentcuration/contentcuration/views/settings.py b/contentcuration/contentcuration/views/settings.py
index 8f2444b158..e8caaceaf1 100644
--- a/contentcuration/contentcuration/views/settings.py
+++ b/contentcuration/contentcuration/views/settings.py
@@ -177,6 +177,8 @@ class StorageSettingsView(PostFormMixin, FormView):
form_class = StorageRequestForm
def form_valid(self, form):
+ self.record_storage_request(self.request.user, form.cleaned_data["storage"])
+
channels = [c for c in form.cleaned_data["public"].split(", ") if c]
message = render_to_string(
"settings/storage_request_email.txt",
@@ -194,6 +196,13 @@ def form_valid(self, form):
[ccsettings.SPACE_REQUEST_EMAIL, self.request.user.email],
)
+ @staticmethod
+ def record_storage_request(user, storage):
+ information = user.information or {}
+ information["latest_storage_request"] = storage
+ user.information = information
+ user.save(update_fields=["information"])
+
class PolicyAcceptView(PostFormMixin, FormView):
form_class = PolicyAcceptForm
diff --git a/contentcuration/contentcuration/viewsets/user.py b/contentcuration/contentcuration/viewsets/user.py
index 126a342319..61bb051e79 100644
--- a/contentcuration/contentcuration/viewsets/user.py
+++ b/contentcuration/contentcuration/viewsets/user.py
@@ -463,7 +463,7 @@ class AdminUserCSVFilter(AdminUserFilter, RequiredFilterSet):
"Has viewable channels",
"Has published a channel",
"Most recent publish date",
- "Has Studio edits",
+ "Has Studio activity",
"Locations (country names)",
"Primary location",
"Location count",
@@ -500,6 +500,10 @@ def _iso_date(value):
return value.date().isoformat() if hasattr(value, "date") else value.isoformat()
+def _storage_needed(info):
+ return info.get("latest_storage_request") or info.get("space_needed") or ""
+
+
def _build_csv_row(values, country_names):
"""Translate one user .values() dict to a CSV row.
@@ -528,7 +532,7 @@ def _build_csv_row(values, country_names):
", ".join(location_names),
location_names[0] if location_names else "",
len(location_codes),
- info.get("space_needed") or "",
+ _storage_needed(info),
info.get("heard_from") or "",
]