From df30823b23d9baff42d33f8f3eb96e61d0da9e51 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 18 Aug 2026 17:21:15 +0300 Subject: [PATCH 1/2] Record requested storage on the user and surface it in the admin CSV A storage request only ever sent an email, so the admin Users CSV "Storage needed" column could only show what a user typed at registration. Users who requested storage afterwards showed blank. Persist the requested amount to user.information and prefer it over the registration answer when building the CSV row. Co-Authored-By: Claude Opus 5 --- .../tests/views/test_settings.py | 57 +++++++++++++++++++ .../tests/viewsets/test_user.py | 20 +++++++ .../contentcuration/views/settings.py | 9 +++ .../contentcuration/viewsets/user.py | 8 ++- 4 files changed, 92 insertions(+), 2 deletions(-) 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 1f801bd182..fc8dc8038a 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_user.py +++ b/contentcuration/contentcuration/tests/viewsets/test_user.py @@ -305,6 +305,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 81855739aa..6c6e2c8565 100644 --- a/contentcuration/contentcuration/viewsets/user.py +++ b/contentcuration/contentcuration/viewsets/user.py @@ -458,7 +458,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", @@ -495,6 +495,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. @@ -523,7 +527,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 "", ] From 3715d35800d32a54b84afca322c817e6eba7f67c Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 18 Aug 2026 17:21:26 +0300 Subject: [PATCH 2/2] Rename, regroup and add a clear action to the admin Users filters "Has Studio edits" matched any Change row the user ever created, so it reported activity rather than edits; renamed to "Has Studio activity" in the filter row and the CSV header. On wide viewports the two checkboxes sat in separate quarter-width columns, leaving a large gap between them. They now share one half-width flex row alongside a "Clear filters" action, which resets every filter while preserving pagination and sorting. The action is enabled only once a filter differs from the default its control already displays. Co-Authored-By: Claude Opus 5 --- .../administration/pages/Users/UserTable.vue | 64 +++++++++++--- .../pages/Users/__tests__/userTable.spec.js | 84 +++++++++++++++++++ 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue index c409ddd82b..c5330a05fd 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Users/UserTable.vue @@ -119,24 +119,23 @@ - - + @@ -208,8 +207,10 @@ import { ref, onMounted, computed, getCurrentInstance } from 'vue'; import { mapGetters } from 'vuex'; + import pick from 'lodash/pick'; import transform from 'lodash/transform'; import { saveAs } from 'file-saver'; + import { useRoute } from 'vue-router/composables'; import { useTable } from '../../composables/useTable'; import { RouteNames, rowsPerPageItems } from '../../constants'; import EmailUsersDialog from './EmailUsersDialog'; @@ -217,6 +218,7 @@ import client from 'shared/client'; import { useFilter } from 'shared/composables/useFilter'; import { useKeywordSearch } from 'shared/composables/useKeywordSearch'; + import { useQueryParams } from 'shared/composables/useQueryParams'; import { routerMixin } from 'shared/mixins'; import IconButton from 'shared/views/IconButton'; import Checkbox from 'shared/views/form/Checkbox'; @@ -230,6 +232,19 @@ sushichef: { label: 'Sushi chef', params: { chef: true } }, }; + const TABLE_STATE_QUERY_PARAMS = ['page', 'page_size', 'sortBy', 'descending']; + + // Mirrors the defaultValue each filter below declares. + const FILTER_DEFAULTS = { + userType: undefined, + location: undefined, + keywords: undefined, + joinedWithin: 'any', + activeWithin: 'any', + hasPublished: 'no', + hasEdits: 'no', + }; + const DATE_WINDOWS = [ { key: 'any', label: 'Any time', months: null }, { key: '1mo', label: 'Last month', months: 1 }, @@ -301,6 +316,8 @@ setup() { const { proxy } = getCurrentInstance(); const store = proxy.$store; + const route = useRoute(); + const { updateQueryParams } = useQueryParams(); const { filter: _userTypeFilter, @@ -368,7 +385,7 @@ const { filter: hasEditsFilter, fetchQueryParams: hasEditsFetchQueryParams } = useBooleanFilter({ name: 'hasEdits', - label: 'Has Studio edits', + label: 'Has Studio activity', paramName: 'has_edits', }); @@ -401,6 +418,16 @@ }; }); + const hasActiveFilters = computed(() => + Object.entries(FILTER_DEFAULTS).some( + ([name, defaultValue]) => (route.query[name] ?? defaultValue) !== defaultValue, + ), + ); + + function clearFilters() { + updateQueryParams(pick(route.query, TABLE_STATE_QUERY_PARAMS)); + } + function loadUsers(fetchParams) { return store.dispatch('userAdmin/loadUsers', fetchParams); } @@ -424,6 +451,8 @@ activeWithinOptions, hasPublishedFilter, hasEditsFilter, + hasActiveFilters, + clearFilters, pagination, loading, loadItems, @@ -525,4 +554,13 @@ - + 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..9b5c4e849c 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Users/__tests__/userTable.spec.js +++ b/contentcuration/contentcuration/frontend/administration/pages/Users/__tests__/userTable.spec.js @@ -104,6 +104,90 @@ describe('userTable', () => { }); }); + describe('clearing filters', () => { + it('is disabled while no filter is applied', () => { + expect(wrapper.vm.hasActiveFilters).toBe(false); + expect(wrapper.findComponent('[data-test="clear-filters"]').props().disabled).toBe(true); + }); + + it('is enabled once a filter is applied', async () => { + wrapper.vm.userTypeFilter = 'administrator'; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.hasActiveFilters).toBe(true); + expect(wrapper.findComponent('[data-test="clear-filters"]').props().disabled).toBe(false); + }); + + it('is enabled by a user type of "All", which narrows nothing but is still a selection', async () => { + wrapper.vm.userTypeFilter = 'all'; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.filterFetchQueryParams).toEqual({}); + expect(wrapper.vm.hasActiveFilters).toBe(true); + expect(wrapper.findComponent('[data-test="clear-filters"]').props().disabled).toBe(false); + }); + + it('stays disabled for date windows left at their default', async () => { + wrapper.vm.joinedWithinFilter = 'any'; + wrapper.vm.activeWithinFilter = 'any'; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.hasActiveFilters).toBe(false); + }); + + it('stays disabled after a checkbox is ticked and unticked again', async () => { + wrapper.vm.hasPublishedFilter = true; + await wrapper.vm.$nextTick(); + expect(wrapper.vm.hasActiveFilters).toBe(true); + + wrapper.vm.hasPublishedFilter = false; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.hasActiveFilters).toBe(false); + }); + + it('drops every filter, including the keyword search', async () => { + jest.useFakeTimers(); + wrapper.vm.keywordInput = 'keyword test'; + wrapper.vm.setKeywords(); + jest.runAllTimers(); + jest.useRealTimers(); + + wrapper.vm.userTypeFilter = 'administrator'; + wrapper.vm.locationFilter = 'Afghanistan'; + wrapper.vm.joinedWithinFilter = '3mo'; + wrapper.vm.activeWithinFilter = '1mo'; + wrapper.vm.hasPublishedFilter = true; + wrapper.vm.hasEditsFilter = true; + await wrapper.vm.$nextTick(); + expect(wrapper.vm.filterFetchQueryParams).not.toEqual({}); + + await wrapper.findComponent('[data-test="clear-filters"]').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.filterFetchQueryParams).toEqual({}); + expect(wrapper.vm.keywordInput).toBe(''); + expect(Object.keys(router.currentRoute.query).sort()).toEqual([ + 'descending', + 'page', + 'page_size', + 'sortBy', + ]); + }); + + it('preserves pagination and sorting', async () => { + wrapper.vm.pagination = { ...wrapper.vm.pagination, page: 3, sortBy: 'email' }; + wrapper.vm.userTypeFilter = 'administrator'; + await wrapper.vm.$nextTick(); + + wrapper.vm.clearFilters(); + await wrapper.vm.$nextTick(); + + expect(router.currentRoute.query.sortBy).toBe('email'); + expect(router.currentRoute.query.userType).toBeUndefined(); + }); + }); + describe('selection', () => { it('selectAll should set selected to channel list', () => { wrapper.vm.selectAll = true;