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 @@

- - + 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 "", ]