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